From 82495d24d437c48702a9fd6d863731e5e1899dac Mon Sep 17 00:00:00 2001 From: radmirnovii Date: Wed, 22 Jul 2026 03:10:28 +0300 Subject: [PATCH 1/4] fix: stop double JSON-encoding execute_sql and get_logs results (#311) Results were JSON-encoded twice: once inside the untrusted-data wrapper and again when the output object was serialized into MCP text content, so backslashes in queried data reached the LLM quadrupled and corrupted round-trips of function definitions. Tools can now provide a `textContent` serializer whose text is sent verbatim, with the full output also returned as `structuredContent` for typed clients. execute_sql and get_logs use it, so results are encoded exactly once. Includes a regression test for the E'\\' round-trip. --- .../mcp-server-supabase/src/server.test.ts | 142 +++++++++++++++--- .../src/tools/database-operation-tools.ts | 5 +- .../src/tools/debugging-tools.ts | 5 +- .../mcp-server-supabase/src/tools/util.ts | 9 ++ packages/mcp-utils/src/server.test.ts | 33 ++++ packages/mcp-utils/src/server.ts | 17 +++ 6 files changed, 187 insertions(+), 24 deletions(-) diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 97930958..ae71e4a5 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 0d4aaebe..97930c67 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 5b5b45bb..5aaca48a 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -35,7 +35,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'), }); function buildQueryLogsInputSchema(sqlDescription: string) { @@ -220,6 +222,7 @@ export function getDebuggingTools({ // available; keep get_logs callable for platforms/clients without it. hidden: Boolean(queryLogs), 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 14cd7807..499260e2 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 a0fea73b..3b7281a5 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 636a4c40..c3488788 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) }] From c38a6fbd35c580bf8150b3c72609952be157ac99 Mon Sep 17 00:00:00 2001 From: radmirnovii Date: Thu, 13 Aug 2026 22:38:19 +0300 Subject: [PATCH 2/4] docs: note execute_sql and get_logs send structuredContent --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f2b72747..3802f08b 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ const tools = await mcpClient.tools({ ``` > [!NOTE] -> This server does not send `structuredContent` in MCP tool results. AI SDK falls back to parsing JSON from `content` text. +> Most tools in this server do not send `structuredContent` in MCP tool results, so AI SDK falls back to parsing JSON from `content` text. The exceptions are `execute_sql` and `get_logs`, which do send `structuredContent` — AI SDK validates it directly. Their `content` text is prose (an untrusted-data wrapper around JSON) rather than a JSON object. For more information, see [Schema Definition](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools#schema-definition) and [Typed Tool Outputs](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools#typed-tool-outputs) in the AI SDK docs. From 35884e58e0c31c662756046fa7327fcf63b51fa9 Mon Sep 17 00:00:00 2001 From: radmirnovii Date: Thu, 13 Aug 2026 22:38:19 +0300 Subject: [PATCH 3/4] test: cover textContent error path, injection, and get_logs encoding --- .../mcp-server-supabase/src/server.test.ts | 50 +++++++++++++++++++ .../src/tools/util.test.ts | 35 +++++++++++++ packages/mcp-utils/src/server.test.ts | 36 +++++++++++++ 3 files changed, 121 insertions(+) diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index ae71e4a5..df6b87af 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -2170,6 +2170,56 @@ describe('tools', () => { ); }); + test('get_logs encodes results exactly once and returns structured content', async () => { + const { client } = 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'; + + const logs = [{ event_message: String.raw`error reading C:\temp\file` }]; + + mockServer?.use( + http.get<{ projectId: string }>( + `${API_URL}/v1/projects/:projectId/analytics/endpoints/logs`, + () => HttpResponse.json(logs) + ) + ); + + const output = await client.callTool({ + name: 'get_logs', + arguments: { + project_id: project.id, + service: 'api', + }, + }); + + const result = CallToolResultSchema.parse(output); + const [textContent] = result.content; + + if (!textContent || textContent.type !== 'text') { + throw new Error('expected text content'); + } + + // The single backslashes in the log message must appear JSON-encoded + // exactly once (2 backslashes in the text), not twice (4 backslashes) + expect(textContent.text).toContain(JSON.stringify(logs)); + expect(textContent.text).toContain(String.raw`C:\\temp\\file`); + expect(textContent.text).not.toContain(String.raw`C:\\\\temp\\\\file`); + + // The full output is still available as structured content for typed clients + expect(result.structuredContent).toEqual({ result: textContent.text }); + }); + test('query logs forwards custom sql and defaults the timestamp window', async () => { const { callTool } = await setup(); diff --git a/packages/mcp-server-supabase/src/tools/util.test.ts b/packages/mcp-server-supabase/src/tools/util.test.ts index 9a6d0304..ec81f8f5 100644 --- a/packages/mcp-server-supabase/src/tools/util.test.ts +++ b/packages/mcp-server-supabase/src/tools/util.test.ts @@ -129,4 +129,39 @@ describe('injectableTool', () => { callTool({ name: 'hidden_tool', arguments: { foo: 'bar' } }) ).resolves.toEqual({ value: 'bar' }); }); + + test('textContent propagates when parameters are injected', async () => { + const { client } = await setup({ + wrapped_tool: injectableTool({ + description: 'A tool with a custom text serializer', + annotations: { + title: 'Wrapped tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + parameters: z.object({ project_id: z.string(), foo: z.string() }), + outputSchema: z.object({ value: z.string() }), + inject: { project_id: 'abc' }, + textContent: ({ value }) => `wrapped: ${value}`, + execute: async ({ foo }) => ({ value: foo }), + }), + }); + + const output = await client.callTool({ + name: 'wrapped_tool', + arguments: { foo: 'bar' }, + }); + const result = CallToolResultSchema.parse(output); + const [textContent] = result.content; + + if (!textContent || textContent.type !== 'text') { + throw new Error('expected text content'); + } + + // The serializer must survive the tool rebuild that removes injected params + expect(textContent.text).toBe('wrapped: bar'); + expect(result.structuredContent).toEqual({ value: 'bar' }); + }); }); diff --git a/packages/mcp-utils/src/server.test.ts b/packages/mcp-utils/src/server.test.ts index 3b7281a5..e9edba89 100644 --- a/packages/mcp-utils/src/server.test.ts +++ b/packages/mcp-utils/src/server.test.ts @@ -152,6 +152,42 @@ describe('tools', () => { expect(result.structuredContent).toEqual({ message }); }); + test('textContent is not applied to error results', async () => { + const server = createMcpServer({ + name: 'test-server', + version: '0.0.0', + tools: { + failing: tool({ + description: 'Always fails', + parameters: z.object({}), + outputSchema: z.object({ message: z.string() }), + textContent: ({ message }) => message, + execute: async () => { + throw new Error('something went wrong'); + }, + }), + }, + }); + + const { client } = await setup({ server }); + + const output = await client.callTool({ name: 'failing', arguments: {} }); + const result = CallToolResultSchema.parse(output); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + + const [textContent] = result.content; + + if (!textContent || textContent.type !== 'text') { + throw new Error('expected text content'); + } + + // Errors keep the default JSON encoding instead of the custom serializer + const parsed = JSON.parse(textContent.text); + expect(parsed.error.message).toBe('something went wrong'); + }); + test('tool callback is called for success and errors', async () => { const onToolCall = vi.fn(); From 4922ca4b33f9684adc2103782f50f64ba3e4a145 Mon Sep 17 00:00:00 2001 From: radmirnovii Date: Thu, 13 Aug 2026 22:45:10 +0300 Subject: [PATCH 4/4] fix: stop double JSON-encoding query_logs results --- README.md | 2 +- .../mcp-server-supabase/src/server.test.ts | 54 ++++++++++++++++++- .../src/tools/debugging-tools.ts | 5 +- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3802f08b..b98c33f5 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ const tools = await mcpClient.tools({ ``` > [!NOTE] -> Most tools in this server do not send `structuredContent` in MCP tool results, so AI SDK falls back to parsing JSON from `content` text. The exceptions are `execute_sql` and `get_logs`, which do send `structuredContent` — AI SDK validates it directly. Their `content` text is prose (an untrusted-data wrapper around JSON) rather than a JSON object. +> Most tools in this server do not send `structuredContent` in MCP tool results, so AI SDK falls back to parsing JSON from `content` text. The exceptions are `execute_sql`, `get_logs`, and `query_logs`, which do send `structuredContent` — AI SDK validates it directly. Their `content` text is prose (an untrusted-data wrapper around JSON) rather than a JSON object. For more information, see [Schema Definition](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools#schema-definition) and [Typed Tool Outputs](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools#typed-tool-outputs) in the AI SDK docs. diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index df6b87af..d467947e 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -2254,7 +2254,7 @@ describe('tools', () => { "select id, timestamp, event_message from logs where source = 'postgres_logs' order by timestamp desc limit 10"; const before = Date.now(); - const { result } = await callTool({ + const result = await callTool({ name: 'query_logs', arguments: { project_id: project.id, @@ -2473,6 +2473,56 @@ describe('tools', () => { ).rejects.toThrow(/too_small|at least 1 character/); }); + test('query_logs encodes results exactly once and returns structured content', async () => { + const { client } = 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'; + + const logs = [{ event_message: String.raw`error reading C:\temp\file` }]; + + mockServer?.use( + http.get<{ projectId: string }>( + `${API_URL}/v1/projects/:projectId/analytics/endpoints/logs`, + () => HttpResponse.json(logs) + ) + ); + + const output = await client.callTool({ + name: 'query_logs', + arguments: { + project_id: project.id, + sql: 'select event_message from logs limit 1', + }, + }); + + const result = CallToolResultSchema.parse(output); + const [textContent] = result.content; + + if (!textContent || textContent.type !== 'text') { + throw new Error('expected text content'); + } + + // The single backslashes in the log message must appear JSON-encoded + // exactly once (2 backslashes in the text), not twice (4 backslashes) + expect(textContent.text).toContain(JSON.stringify(logs)); + expect(textContent.text).toContain(String.raw`C:\\temp\\file`); + expect(textContent.text).not.toContain(String.raw`C:\\\\temp\\\\file`); + + // The full output is still available as structured content for typed clients + expect(result.structuredContent).toEqual({ result: textContent.text }); + }); + test('get security advisors', async () => { const { callTool } = await setup(); @@ -4167,7 +4217,7 @@ describe('feature groups', () => { }); project.status = 'ACTIVE_HEALTHY'; - 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/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index 5aaca48a..33e14b2d 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -116,7 +116,9 @@ const queryLogsByDialect = { >; const queryLogsOutputSchema = z.object({ - result: z.unknown(), + result: z + .string() + .describe('Logs as JSON wrapped in an untrusted-data boundary'), }); const getAdvisorsInputSchema = z.object({ @@ -244,6 +246,7 @@ export function getDebuggingTools({ description: queryLogsByDialect[logsDialect].description, parameters: queryLogsByDialect[logsDialect].parameters, inject: { project_id }, + textContent: ({ result }) => result, execute: async ({ project_id, sql,