Skip to content

Commit d9d9f0e

Browse files
authored
feat: lazy graphql schema (#166)
* feat: support lazy tool definitions, disable client schema validation by default * feat: lazy tool descriptions * chore: cleanup logs * docs: restore comment * feat: revert lazy tool definitions, just use lazy description * chore: cleanup * chore: unused imports * test: schema is only loaded when listing tools
1 parent cc1e3ed commit d9d9f0e

File tree

6 files changed

+66
-34
lines changed

6 files changed

+66
-34
lines changed

packages/mcp-server-supabase/src/content-api/graphql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export class GraphQLClient {
121121
*/
122122
async query(
123123
request: GraphQLRequest,
124-
options: QueryOptions = { validateSchema: true }
124+
options: QueryOptions = { validateSchema: false }
125125
) {
126126
try {
127127
// Check that this is a valid GraphQL query

packages/mcp-server-supabase/src/content-api/index.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const contentApiSchemaResponseSchema = z.object({
66
});
77

88
export type ContentApiClient = {
9-
schema: string;
9+
loadSchema: () => Promise<string>;
1010
query: QueryFn;
1111
setUserAgent: (userAgent: string) => void;
1212
};
@@ -18,18 +18,15 @@ export async function createContentApiClient(
1818
const graphqlClient = new GraphQLClient({
1919
url,
2020
headers,
21+
});
22+
23+
return {
2124
// Content API provides schema string via `schema` query
22-
loadSchema: async ({ query }) => {
23-
const response = await query({ query: '{ schema }' });
25+
loadSchema: async () => {
26+
const response = await graphqlClient.query({ query: '{ schema }' });
2427
const { schema } = contentApiSchemaResponseSchema.parse(response);
2528
return schema;
2629
},
27-
});
28-
29-
const { source } = await graphqlClient.schemaLoaded;
30-
31-
return {
32-
schema: source,
3330
async query(request: GraphQLRequest) {
3431
return graphqlClient.query(request);
3532
},

packages/mcp-server-supabase/src/server.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ACCESS_TOKEN,
1212
API_URL,
1313
contentApiMockSchema,
14+
mockContentApiSchemaLoadCount,
1415
createOrganization,
1516
createProject,
1617
createBranch,
@@ -31,6 +32,7 @@ beforeEach(async () => {
3132
mockOrgs.clear();
3233
mockProjects.clear();
3334
mockBranches.clear();
35+
mockContentApiSchemaLoadCount.value = 0;
3436

3537
const server = setupServer(...mockContentApi, ...mockManagementApi);
3638
server.listen({ onUnhandledRequest: 'error' });
@@ -2943,4 +2945,27 @@ describe('docs tools', () => {
29432945

29442946
expect(tool.description.includes(contentApiMockSchema)).toBe(true);
29452947
});
2948+
2949+
test('schema is only loaded when listing tools', async () => {
2950+
const { client, callTool } = await setup();
2951+
2952+
expect(mockContentApiSchemaLoadCount.value).toBe(0);
2953+
2954+
// "tools/list" requests fetch the schema
2955+
await client.listTools();
2956+
expect(mockContentApiSchemaLoadCount.value).toBe(1);
2957+
2958+
// "tools/call" should not fetch the schema again
2959+
await callTool({
2960+
name: 'search_docs',
2961+
arguments: {
2962+
graphql_query: '{ searchDocs(query: "test") { nodes { title } } }',
2963+
},
2964+
});
2965+
expect(mockContentApiSchemaLoadCount.value).toBe(1);
2966+
2967+
// Additional "tools/list" requests fetch the schema again
2968+
await client.listTools();
2969+
expect(mockContentApiSchemaLoadCount.value).toBe(2);
2970+
});
29462971
});

packages/mcp-server-supabase/src/tools/docs-tools.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ export type DocsToolsOptions = {
1010
export function getDocsTools({ contentApiClient }: DocsToolsOptions) {
1111
return {
1212
search_docs: tool({
13-
description: source`
14-
Search the Supabase documentation using GraphQL. Must be a valid GraphQL query.
13+
description: async () => {
14+
const schema = await contentApiClient.loadSchema();
1515

16-
You should default to calling this even if you think you already know the answer, since the documentation is always being updated.
17-
18-
Below is the GraphQL schema for the Supabase docs endpoint:
19-
${contentApiClient.schema}
20-
`,
16+
return source`
17+
Search the Supabase documentation using GraphQL. Must be a valid GraphQL query.
18+
You should default to calling this even if you think you already know the answer, since the documentation is always being updated.
19+
Below is the GraphQL schema for the Supabase docs endpoint:
20+
${schema}
21+
`;
22+
},
2123
annotations: {
2224
title: 'Search docs',
2325
readOnlyHint: true,

packages/mcp-server-supabase/test/mocks.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export const mockOrgs = new Map<string, Organization>();
8484
export const mockProjects = new Map<string, MockProject>();
8585
export const mockBranches = new Map<string, MockBranch>();
8686

87+
export const mockContentApiSchemaLoadCount = { value: 0 };
88+
8789
export const mockContentApi = [
8890
http.post(CONTENT_API_URL, async ({ request }) => {
8991
const json = await request.json();
@@ -96,6 +98,7 @@ export const mockContentApi = [
9698
const [queryName] = getQueryFields(document);
9799

98100
if (queryName === 'schema') {
101+
mockContentApiSchemaLoadCount.value++;
99102
return HttpResponse.json({
100103
data: {
101104
schema: contentApiMockSchema,

packages/mcp-utils/src/server.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export type Tool<
5454
Params extends z.ZodObject<any> = z.ZodObject<any>,
5555
Result = unknown,
5656
> = {
57-
description: string;
57+
description: Prop<string>;
5858
annotations?: Annotations;
5959
parameters: Params;
6060
execute(params: z.infer<Params>): Promise<Result>;
@@ -436,24 +436,30 @@ export function createMcpServer(options: McpServerOptions) {
436436
ListToolsRequestSchema,
437437
async (): Promise<ListToolsResult> => {
438438
const tools = await getTools();
439-
return {
440-
tools: Object.entries(tools).map(
441-
([name, { description, annotations, parameters }]) => {
442-
const inputSchema = zodToJsonSchema(parameters);
443439

444-
if (!('properties' in inputSchema)) {
445-
throw new Error('tool parameters must be a ZodObject');
440+
return {
441+
tools: await Promise.all(
442+
Object.entries(tools).map(
443+
async ([name, { description, annotations, parameters }]) => {
444+
const inputSchema = zodToJsonSchema(parameters);
445+
446+
if (!('properties' in inputSchema)) {
447+
throw new Error('tool parameters must be a ZodObject');
448+
}
449+
450+
return {
451+
name,
452+
description:
453+
typeof description === 'function'
454+
? await description()
455+
: description,
456+
annotations,
457+
inputSchema,
458+
};
446459
}
447-
448-
return {
449-
name,
450-
description,
451-
annotations,
452-
inputSchema,
453-
};
454-
}
460+
)
455461
),
456-
};
462+
} satisfies ListToolsResult;
457463
}
458464
);
459465

@@ -471,7 +477,6 @@ export function createMcpServer(options: McpServerOptions) {
471477
if (!tool) {
472478
throw new Error('tool not found');
473479
}
474-
475480
const args = tool.parameters
476481
.strict()
477482
.parse(request.params.arguments ?? {});

0 commit comments

Comments
 (0)