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
142 changes: 120 additions & 22 deletions packages/mcp-server-supabase/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -860,14 +865,10 @@ describe('tools', () => {
},
});

expect(result.result).toContain('untrusted user data');
expect(result.result).toMatch(
/<untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/
);
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(/<untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/);
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 () => {
Expand Down Expand Up @@ -896,14 +897,111 @@ describe('tools', () => {
},
});

expect(result.result).toContain('untrusted user data');
expect(result.result).toMatch(
/<untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/
);
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(/<untrusted-data-\w{8}-\w{4}-\w{4}-\w{4}-\w{12}>/);
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(
/<untrusted-data-[\w-]+>\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 () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/mcp-server-supabase/src/tools/debugging-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -85,6 +87,7 @@ export function getDebuggingTools({
get_logs: injectableTool({
...debuggingToolDefs.get_logs,
inject: { project_id },
textContent: ({ result }) => result,
execute: async ({
project_id,
service,
Expand Down
9 changes: 9 additions & 0 deletions packages/mcp-server-supabase/src/tools/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function injectableTool<
parameters,
outputSchema,
hidden,
textContent,
inject,
execute,
}: InjectableTool<Params, OutputSchema, Injected>) {
Expand All @@ -55,6 +56,7 @@ export function injectableTool<
parameters,
outputSchema,
hidden,
textContent,
execute,
});
}
Expand Down Expand Up @@ -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();

Expand Down
33 changes: 33 additions & 0 deletions packages/mcp-utils/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
17 changes: 17 additions & 0 deletions packages/mcp-utils/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OutputSchema>): string;
execute(params: z.infer<Params>): Promise<z.infer<OutputSchema>>;
};

Expand Down Expand Up @@ -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<string, unknown>;
return {
content: [{ type: 'text', text: tool.textContent(output) }],
structuredContent: output,
};
}

const content =
result != null
? [{ type: 'text', text: JSON.stringify(result) }]
Expand Down