diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 596cf0e..9a2b307 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -4,7 +4,7 @@ import { type CallToolRequest, } from '@modelcontextprotocol/sdk/types.js'; import { StreamTransport } from '@supabase/mcp-utils'; -import { codeBlock, stripIndent } from 'common-tags'; +import { codeBlock, source, stripIndent } from 'common-tags'; import gqlmin from 'gqlmin'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; @@ -119,13 +119,18 @@ async function setup(options: SetupOptions = {}) { throw new Error('tool result content is empty'); } - const result = JSON.parse(textContent.text); - if (output.isError) { + const result = JSON.parse(textContent.text); throw new Error(result.error.message); } - return result; + // Tools with a string output schema return plain text; everything else + // returns JSON. + try { + return JSON.parse(textContent.text); + } catch { + return textContent.text; + } } return { client, clientTransport, callTool, server, serverTransport }; @@ -860,14 +865,10 @@ describe('tools', () => { }, }); - expect(result.result).toContain('untrusted user data'); - expect(result.result).toMatch( - // - ); - expect(result.result).toContain(JSON.stringify([{ sum: 2 }])); - expect(result.result).toMatch( - /<\/untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/ - ); + expect(result).toContain('untrusted user data'); + expect(result).toMatch(//); + expect(result).toContain(JSON.stringify([{ sum: 2 }])); + expect(result).toMatch(/<\/untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/); }); test('can run read queries in read-only mode', async () => { @@ -896,14 +897,111 @@ describe('tools', () => { }, }); - expect(result.result).toContain('untrusted user data'); - expect(result.result).toMatch( - // - ); - expect(result.result).toContain(JSON.stringify([{ sum: 2 }])); - expect(result.result).toMatch( - /<\/untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/ - ); + expect(result).toContain('untrusted user data'); + expect(result).toMatch(//); + expect(result).toContain(JSON.stringify([{ sum: 2 }])); + expect(result).toMatch(/<\/untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/); + }); + + test('execute_sql encodes results exactly once (backslash round-trip)', async () => { + const { client, callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + // Function body compares against a literal backslash via an E-string, + // reproducing https://github.com/supabase/mcp/issues/311 + const functionQuery = source` + create or replace function public.encrypt_token() + returns text + language plpgsql + as $function$ + begin + if left('abc', 1) != E'\\\\' then + return 'not backslash'; + end if; + return 'backslash'; + end; + $function$ + `; + + await callTool({ + name: 'execute_sql', + arguments: { + project_id: project.id, + query: functionQuery, + }, + }); + + const readDefinition = async () => { + const output = await client.callTool({ + name: 'execute_sql', + arguments: { + project_id: project.id, + query: + "select pg_get_functiondef(oid) as def from pg_proc where proname = 'encrypt_token'", + }, + }); + + const result = CallToolResultSchema.parse(output); + const [textContent] = result.content; + + if (!textContent || textContent.type !== 'text') { + throw new Error('expected text content'); + } + + // Extract the JSON payload between the (randomly named) boundaries + const embedded = textContent.text.match( + /\n(.*)\n<\/untrusted-data-[\w-]+>/s + )?.[1]; + + if (embedded === undefined) { + throw new Error('expected untrusted-data boundary in text content'); + } + + return { + text: textContent.text, + embedded, + structuredContent: result.structuredContent, + }; + }; + + const { text, embedded, structuredContent } = await readDefinition(); + + // The E-string's two backslashes must appear JSON-encoded exactly once + // (4 backslashes in the text), not twice (8 backslashes) + expect(text).toContain(String.raw`E'\\\\'`); + expect(text).not.toContain(String.raw`E'\\\\\\\\'`); + + // The full output is still available as structured content for typed clients + expect(structuredContent).toEqual({ result: text }); + + // A single JSON decode restores the original definition + const [row] = JSON.parse(embedded); + expect(row.def).toContain(String.raw`E'\\'`); + + // Round-trip: re-creating the function from the returned definition + // must produce an identical function body + await callTool({ + name: 'execute_sql', + arguments: { + project_id: project.id, + query: row.def, + }, + }); + + const { embedded: roundTrippedEmbedded } = await readDefinition(); + expect(roundTrippedEmbedded).toBe(embedded); }); test('cannot run write queries in read-only mode', async () => { @@ -2006,7 +2104,7 @@ describe('tools', () => { ] as const; for (const service of services) { - const { result } = await callTool({ + const result = await callTool({ name: 'get_logs', arguments: { project_id: project.id, @@ -2052,7 +2150,7 @@ describe('tools', () => { const isoTimestampStart = '2024-02-01T10:00:00.000Z'; const isoTimestampEnd = '2024-02-01T11:00:00.000Z'; - const { result } = await callTool({ + const result = await callTool({ name: 'get_logs', arguments: { project_id: project.id, diff --git a/packages/mcp-server-supabase/src/tools/database-operation-tools.ts b/packages/mcp-server-supabase/src/tools/database-operation-tools.ts index 0d4aaeb..97930c6 100644 --- a/packages/mcp-server-supabase/src/tools/database-operation-tools.ts +++ b/packages/mcp-server-supabase/src/tools/database-operation-tools.ts @@ -109,7 +109,9 @@ const executeSqlInputSchema = z.object({ }); const executeSqlOutputSchema = z.object({ - result: z.string(), + result: z + .string() + .describe('Query results as JSON wrapped in an untrusted-data boundary'), }); export const databaseToolDefs = { @@ -370,6 +372,7 @@ export function getDatabaseTools({ readOnlyHint: readOnly ?? false, }, inject: { project_id }, + textContent: ({ result }) => result, execute: async ({ query, project_id }) => { const result = await database.executeSql(project_id, { query, diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index b620bc3..2a6560a 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -32,7 +32,9 @@ const getLogsInputSchema = z.object({ }); const getLogsOutputSchema = z.object({ - result: z.unknown(), + result: z + .string() + .describe('Logs as JSON wrapped in an untrusted-data boundary'), }); const getAdvisorsInputSchema = z.object({ @@ -85,6 +87,7 @@ export function getDebuggingTools({ get_logs: injectableTool({ ...debuggingToolDefs.get_logs, inject: { project_id }, + textContent: ({ result }) => result, execute: async ({ project_id, service, diff --git a/packages/mcp-server-supabase/src/tools/util.ts b/packages/mcp-server-supabase/src/tools/util.ts index 14cd780..499260e 100644 --- a/packages/mcp-server-supabase/src/tools/util.ts +++ b/packages/mcp-server-supabase/src/tools/util.ts @@ -44,6 +44,7 @@ export function injectableTool< parameters, outputSchema, hidden, + textContent, inject, execute, }: InjectableTool) { @@ -55,6 +56,7 @@ export function injectableTool< parameters, outputSchema, hidden, + textContent, execute, }); } @@ -82,10 +84,17 @@ export function injectableTool< parameters: cleanParametersSchema, outputSchema, hidden, + textContent, execute: executeWithInjection, }); } +/** + * Wraps untrusted data in a prompt-injection boundary. + * + * The data is JSON-encoded exactly once here. Tools returning this string + * must extract it via `textContent` so it is not encoded a second time. + */ export function wrapWithUntrustedDataBoundary(result: unknown) { const uuid = crypto.randomUUID(); diff --git a/packages/mcp-utils/src/server.test.ts b/packages/mcp-utils/src/server.test.ts index a0fea73..3b7281a 100644 --- a/packages/mcp-utils/src/server.test.ts +++ b/packages/mcp-utils/src/server.test.ts @@ -119,6 +119,39 @@ describe('tools', () => { }); }); + test('textContent sends text verbatim and returns structured content', async () => { + const message = 'Line one\nContains a backslash: \\ and "quotes"'; + + const server = createMcpServer({ + name: 'test-server', + version: '0.0.0', + tools: { + echo: tool({ + description: 'Echo a message', + parameters: z.object({}), + outputSchema: z.object({ message: z.string() }), + textContent: ({ message }) => message, + execute: async () => ({ message }), + }), + }, + }); + + const { client } = await setup({ server }); + + const output = await client.callTool({ name: 'echo', arguments: {} }); + const result = CallToolResultSchema.parse(output); + const [textContent] = result.content; + + if (!textContent || textContent.type !== 'text') { + throw new Error('expected text content'); + } + + // Sent verbatim — not JSON-encoded a second time + expect(textContent.text).toBe(message); + + expect(result.structuredContent).toEqual({ message }); + }); + test('tool callback is called for success and errors', async () => { const onToolCall = vi.fn(); diff --git a/packages/mcp-utils/src/server.ts b/packages/mcp-utils/src/server.ts index 636a4c4..c348878 100644 --- a/packages/mcp-utils/src/server.ts +++ b/packages/mcp-utils/src/server.ts @@ -62,6 +62,13 @@ export type Tool< outputSchema: OutputSchema; /** If true, excludes the tool from `tools/list` while keeping it callable via `tools/call`. */ hidden?: boolean; + /** + * Optional serializer for the result's text content, sent verbatim in + * place of the default `JSON.stringify(output)`. The output object is then + * also returned as `structuredContent` for typed clients. Prevents double + * JSON-encoding of display text (https://github.com/supabase/mcp/issues/311). + */ + textContent?(output: z.infer): string; execute(params: z.infer): Promise>; }; @@ -525,6 +532,16 @@ export function createMcpServer(options: McpServerOptions) { const result = await executeWithCallback(tool); + if (result != null && tool.textContent) { + // Text is sent verbatim (already serialized by the tool); the raw + // output goes in structuredContent for typed clients. + const output = result as Record; + return { + content: [{ type: 'text', text: tool.textContent(output) }], + structuredContent: output, + }; + } + const content = result != null ? [{ type: 'text', text: JSON.stringify(result) }]