Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/mcp-server-supabase/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3016,6 +3016,51 @@ describe('tools', () => {
}
});

test('read-only mode excludes write tools from tools/list', async () => {
const { callTool, client } = await setup({
readOnly: true,
features: [
'docs',
'account',
'database',
'debugging',
'development',
'functions',
'branching',
'storage',
],
});

const { tools } = await client.listTools();
const toolNames = tools.map((tool) => tool.name);

expect(toolNames).toContain('execute_sql');
expect(toolNames).not.toContain('apply_migration');
expect(toolNames).not.toContain('deploy_edge_function');
expect(toolNames).not.toContain('create_branch');
expect(toolNames).not.toContain('delete_branch');
expect(toolNames).not.toContain('update_storage_config');

expect(
tools
.filter((tool) => tool.annotations?.readOnlyHint === false)
.map((tool) => tool.name)
).toEqual([]);

const result = callTool({
name: 'apply_migration',
arguments: {
project_id: 'test-project-ref',
name: 'test-migration',
query: 'create table test (id int)',
},
});

await expect(result).rejects.toThrow(
'Cannot apply migration in read-only mode.'
);
});
Comment thread
Lubrsy706 marked this conversation as resolved.

test('tool result content is valid JSON', async () => {
const org = await createOrganization({
name: 'My Org',
Expand Down
133 changes: 73 additions & 60 deletions packages/mcp-server-supabase/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getDevelopmentTools } from './tools/development-tools.js';
import { getDocsTools } from './tools/docs-tools.js';
import { getEdgeFunctionTools } from './tools/edge-function-tools.js';
import { getStorageTools } from './tools/storage-tools.js';
import { writeToolSet } from './tools/tool-schemas.js';
import type { FeatureGroup } from './types.js';
import { parseFeatureGroups } from './util.js';

Expand Down Expand Up @@ -67,6 +68,12 @@ const DEFAULT_FEATURES: FeatureGroup[] = [

export const PLATFORM_INDEPENDENT_FEATURES: FeatureGroup[] = ['docs'];

function removeWriteTools(tools: Record<string, Tool>) {
Comment thread
Lubrsy706 marked this conversation as resolved.
return Object.fromEntries(
Object.entries(tools).filter(([name]) => !writeToolSet.has(name))
);
}

export const instructions = `
Here are guidelines for using Supabase tools effectively:

Expand Down Expand Up @@ -114,6 +121,68 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
features ?? availableDefaultFeatures
);

async function getTools() {
const contentApiClient = await contentApiClientPromise;
const tools: Record<string, Tool> = {};

const {
account,
database,
functions,
debugging,
development,
storage,
branching,
} = platform;

if (enabledFeatures.has('docs')) {
Object.assign(tools, getDocsTools({ contentApiClient }));
}

if (!projectId && account && enabledFeatures.has('account')) {
Object.assign(tools, getAccountTools({ account, readOnly }));
}

if (database && enabledFeatures.has('database')) {
Object.assign(
tools,
getDatabaseTools({
database,
projectId,
readOnly,
})
);
}

if (debugging && enabledFeatures.has('debugging')) {
Object.assign(tools, getDebuggingTools({ debugging, projectId }));
}

if (development && enabledFeatures.has('development')) {
Object.assign(tools, getDevelopmentTools({ development, projectId }));
}

if (functions && enabledFeatures.has('functions')) {
Object.assign(
tools,
getEdgeFunctionTools({ functions, projectId, readOnly })
);
}

if (branching && enabledFeatures.has('branching')) {
Object.assign(
tools,
getBranchingTools({ branching, projectId, readOnly })
);
}

if (storage && enabledFeatures.has('storage')) {
Object.assign(tools, getStorageTools({ storage, projectId, readOnly }));
}

return tools;
}

const server = createMcpServer({
name: 'supabase',
title: 'Supabase',
Expand All @@ -133,66 +202,10 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) {
]);
},
onToolCall,
tools: async () => {
const contentApiClient = await contentApiClientPromise;
const tools: Record<string, Tool> = {};

const {
account,
database,
functions,
debugging,
development,
storage,
branching,
} = platform;

if (enabledFeatures.has('docs')) {
Object.assign(tools, getDocsTools({ contentApiClient }));
}

if (!projectId && account && enabledFeatures.has('account')) {
Object.assign(tools, getAccountTools({ account, readOnly }));
}

if (database && enabledFeatures.has('database')) {
Object.assign(
tools,
getDatabaseTools({
database,
projectId,
readOnly,
})
);
}

if (debugging && enabledFeatures.has('debugging')) {
Object.assign(tools, getDebuggingTools({ debugging, projectId }));
}

if (development && enabledFeatures.has('development')) {
Object.assign(tools, getDevelopmentTools({ development, projectId }));
}

if (functions && enabledFeatures.has('functions')) {
Object.assign(
tools,
getEdgeFunctionTools({ functions, projectId, readOnly })
);
}

if (branching && enabledFeatures.has('branching')) {
Object.assign(
tools,
getBranchingTools({ branching, projectId, readOnly })
);
}

if (storage && enabledFeatures.has('storage')) {
Object.assign(tools, getStorageTools({ storage, projectId, readOnly }));
}

return tools;
tools: getTools,
listedTools: async () => {
const tools = await getTools();
return readOnly ? removeWriteTools(tools) : tools;
},
});

Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server-supabase/src/tools/tool-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ const PROJECT_SCOPED_OVERRIDES: Record<string, SchemaEntry> =
* Derived from tool defs: any tool with `readOnlyHint: false` and no
* `readOnlyBehavior: 'adapt'` annotation.
*/
const writeToolSet = new Set(
export const writeToolSet = new Set(
Object.entries(supabaseMcpToolSchemas)
.filter(
([, entry]) =>
Expand Down
22 changes: 21 additions & 1 deletion packages/mcp-utils/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,15 @@ export type McpServerOptions = {
* that can change after the server has started.
*/
tools?: Prop<Record<string, Tool>>;

/**
* Optional tool subset to expose from `tools/list`.
*
* When omitted, `tools/list` uses the same tool set as tool calls. This lets
* servers hide tools from planning while still returning a clear runtime
* error if an older client calls a previously listed tool.
*/
listedTools?: Prop<Record<string, Tool>>;
};

/**
Expand Down Expand Up @@ -316,6 +325,17 @@ export function createMcpServer(options: McpServerOptions) {
: options.tools;
}

async function getListedTools() {
if (!options.tools) {
throw new Error('tools not available');
}

const listedTools = options.listedTools ?? options.tools;
return typeof listedTools === 'function'
? await listedTools()
: listedTools;
}

server.oninitialized = async () => {
const clientInfo = server.getClientVersion();
const clientCapabilities = server.getClientCapabilities();
Expand Down Expand Up @@ -449,7 +469,7 @@ export function createMcpServer(options: McpServerOptions) {
server.setRequestHandler(
ListToolsRequestSchema,
async (): Promise<ListToolsResult> => {
const tools = await getTools();
const tools = await getListedTools();

return {
tools: await Promise.all(
Expand Down