Skip to content

Commit 682e6cf

Browse files
authored
Merge pull request #90 from supabase-community/feat/docs-tooling
feat(tools): add search_docs tool
2 parents d0c7fe4 + d15d9df commit 682e6cf

File tree

12 files changed

+562
-29
lines changed

12 files changed

+562
-29
lines changed

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/mcp-server-supabase/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@modelcontextprotocol/sdk": "^1.11.0",
3939
"@supabase/mcp-utils": "0.2.1",
4040
"common-tags": "^1.8.2",
41+
"graphql": "^16.11.0",
4142
"openapi-fetch": "^0.13.5",
4243
"zod": "^3.24.1"
4344
},
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { stripIndent } from 'common-tags';
2+
import { describe, expect, it } from 'vitest';
3+
import { GraphQLClient } from './graphql.js';
4+
5+
describe('graphql client', () => {
6+
it('should load schema', async () => {
7+
const schema = stripIndent`
8+
schema {
9+
query: RootQueryType
10+
}
11+
type RootQueryType {
12+
message: String!
13+
}
14+
`;
15+
16+
const graphqlClient = new GraphQLClient({
17+
url: 'dummy-url',
18+
loadSchema: async () => schema,
19+
});
20+
21+
const { source } = await graphqlClient.schemaLoaded;
22+
23+
expect(source).toBe(schema);
24+
});
25+
26+
it('should throw error if validation requested but loadSchema not provided', async () => {
27+
const graphqlClient = new GraphQLClient({
28+
url: 'dummy-url',
29+
});
30+
31+
await expect(
32+
graphqlClient.query(
33+
{ query: '{ getHelloWorld }' },
34+
{ validateSchema: true }
35+
)
36+
).rejects.toThrow('No schema loader provided');
37+
});
38+
39+
it('should throw for invalid query regardless of schema', async () => {
40+
const graphqlClient = new GraphQLClient({
41+
url: 'dummy-url',
42+
});
43+
44+
await expect(
45+
graphqlClient.query({ query: 'invalid graphql query' })
46+
).rejects.toThrow(
47+
'Invalid GraphQL query: Syntax Error: Unexpected Name "invalid"'
48+
);
49+
});
50+
51+
it("should throw error if query doesn't match schema", async () => {
52+
const schema = stripIndent`
53+
schema {
54+
query: RootQueryType
55+
}
56+
type RootQueryType {
57+
message: String!
58+
}
59+
`;
60+
61+
const graphqlClient = new GraphQLClient({
62+
url: 'dummy-url',
63+
loadSchema: async () => schema,
64+
});
65+
66+
await expect(
67+
graphqlClient.query(
68+
{ query: '{ invalidField }' },
69+
{ validateSchema: true }
70+
)
71+
).rejects.toThrow(
72+
'Invalid GraphQL query: Cannot query field "invalidField" on type "RootQueryType"'
73+
);
74+
});
75+
76+
it('bubbles up loadSchema errors', async () => {
77+
const graphqlClient = new GraphQLClient({
78+
url: 'dummy-url',
79+
loadSchema: async () => {
80+
throw new Error('Failed to load schema');
81+
},
82+
});
83+
84+
await expect(graphqlClient.schemaLoaded).rejects.toThrow(
85+
'Failed to load schema'
86+
);
87+
});
88+
});
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import {
2+
buildSchema,
3+
GraphQLError,
4+
GraphQLSchema,
5+
parse,
6+
validate,
7+
type DocumentNode,
8+
} from 'graphql';
9+
import { z } from 'zod';
10+
11+
export const graphqlRequestSchema = z.object({
12+
query: z.string(),
13+
variables: z.record(z.string(), z.unknown()).optional(),
14+
});
15+
16+
export const graphqlResponseSuccessSchema = z.object({
17+
data: z.record(z.string(), z.unknown()),
18+
errors: z.undefined(),
19+
});
20+
21+
export const graphqlErrorSchema = z.object({
22+
message: z.string(),
23+
locations: z.array(
24+
z.object({
25+
line: z.number(),
26+
column: z.number(),
27+
})
28+
),
29+
});
30+
31+
export const graphqlResponseErrorSchema = z.object({
32+
data: z.undefined(),
33+
errors: z.array(graphqlErrorSchema),
34+
});
35+
36+
export const graphqlResponseSchema = z.union([
37+
graphqlResponseSuccessSchema,
38+
graphqlResponseErrorSchema,
39+
]);
40+
41+
export type GraphQLRequest = z.infer<typeof graphqlRequestSchema>;
42+
export type GraphQLResponse = z.infer<typeof graphqlResponseSchema>;
43+
44+
export type QueryFn = (
45+
request: GraphQLRequest
46+
) => Promise<Record<string, unknown>>;
47+
48+
export type QueryOptions = {
49+
validateSchema?: boolean;
50+
};
51+
52+
export type GraphQLClientOptions = {
53+
/**
54+
* The URL of the GraphQL endpoint.
55+
*/
56+
url: string;
57+
58+
/**
59+
* A function that loads the GraphQL schema.
60+
* This will be used for validating future queries.
61+
*
62+
* A `query` function is provided that can be used to
63+
* execute GraphQL queries against the endpoint
64+
* (e.g. if the API itself allows querying the schema).
65+
*/
66+
loadSchema?({ query }: { query: QueryFn }): Promise<string>;
67+
68+
/**
69+
* Optional headers to include in the request.
70+
*/
71+
headers?: Record<string, string>;
72+
};
73+
74+
export class GraphQLClient {
75+
#url: string;
76+
#headers: Record<string, string>;
77+
78+
/**
79+
* A promise that resolves when the schema is loaded via
80+
* the `loadSchema` function.
81+
*
82+
* Resolves to an object containing the raw schema source
83+
* string and the parsed GraphQL schema.
84+
*
85+
* Rejects if no `loadSchema` function was provided to
86+
* the constructor.
87+
*/
88+
schemaLoaded: Promise<{
89+
/**
90+
* The raw GraphQL schema string.
91+
*/
92+
source: string;
93+
94+
/**
95+
* The parsed GraphQL schema.
96+
*/
97+
schema: GraphQLSchema;
98+
}>;
99+
100+
/**
101+
* Creates a new GraphQL client.
102+
*/
103+
constructor(options: GraphQLClientOptions) {
104+
this.#url = options.url;
105+
this.#headers = options.headers ?? {};
106+
107+
this.schemaLoaded =
108+
options
109+
.loadSchema?.({ query: this.#query.bind(this) })
110+
.then((source) => ({
111+
source,
112+
schema: buildSchema(source),
113+
})) ?? Promise.reject(new Error('No schema loader provided'));
114+
115+
// Prevent unhandled promise rejections
116+
this.schemaLoaded.catch(() => {});
117+
}
118+
119+
/**
120+
* Executes a GraphQL query against the provided URL.
121+
*/
122+
async query(
123+
request: GraphQLRequest,
124+
options: QueryOptions = { validateSchema: true }
125+
) {
126+
try {
127+
// Check that this is a valid GraphQL query
128+
const documentNode = parse(request.query);
129+
130+
// Validate the query against the schema if requested
131+
if (options.validateSchema) {
132+
const { schema } = await this.schemaLoaded;
133+
const errors = validate(schema, documentNode);
134+
if (errors.length > 0) {
135+
throw new Error(
136+
`Invalid GraphQL query: ${errors.map((e) => e.message).join(', ')}`
137+
);
138+
}
139+
}
140+
141+
return this.#query(request);
142+
} catch (error) {
143+
// Make it obvious that this is a GraphQL error
144+
if (error instanceof GraphQLError) {
145+
throw new Error(`Invalid GraphQL query: ${error.message}`);
146+
}
147+
148+
throw error;
149+
}
150+
}
151+
152+
/**
153+
* Executes a GraphQL query against the provided URL.
154+
*
155+
* Does not validate the query against the schema.
156+
*/
157+
async #query(request: GraphQLRequest) {
158+
const { query, variables } = request;
159+
160+
const response = await fetch(this.#url, {
161+
method: 'POST',
162+
headers: {
163+
...this.#headers,
164+
'Content-Type': 'application/json',
165+
Accept: 'application/json',
166+
},
167+
body: JSON.stringify({
168+
query,
169+
variables,
170+
}),
171+
});
172+
173+
if (!response.ok) {
174+
throw new Error(
175+
`Failed to fetch Supabase Content API GraphQL schema: HTTP status ${response.status}`
176+
);
177+
}
178+
179+
const json = await response.json();
180+
181+
const { data, error } = graphqlResponseSchema.safeParse(json);
182+
183+
if (error) {
184+
throw new Error(
185+
`Failed to parse Supabase Content API response: ${error.message}`
186+
);
187+
}
188+
189+
if (data.errors) {
190+
throw new Error(
191+
`Supabase Content API GraphQL error: ${data.errors
192+
.map(
193+
(err) =>
194+
`${err.message} (line ${err.locations[0]?.line ?? 'unknown'}, column ${err.locations[0]?.column ?? 'unknown'})`
195+
)
196+
.join(', ')}`
197+
);
198+
}
199+
200+
return data.data;
201+
}
202+
}
203+
204+
/**
205+
* Extracts the fields from a GraphQL query document.
206+
*/
207+
export function getQueryFields(document: DocumentNode) {
208+
return document.definitions
209+
.filter((def) => def.kind === 'OperationDefinition')
210+
.flatMap((def) => {
211+
if (def.kind === 'OperationDefinition' && def.selectionSet) {
212+
return def.selectionSet.selections
213+
.filter((sel) => sel.kind === 'Field')
214+
.map((sel) => {
215+
if (sel.kind === 'Field') {
216+
return sel.name.value;
217+
}
218+
return null;
219+
})
220+
.filter(Boolean);
221+
}
222+
return [];
223+
});
224+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { z } from 'zod';
2+
import { GraphQLClient, type GraphQLRequest, type QueryFn } from './graphql.js';
3+
4+
const contentApiSchemaResponseSchema = z.object({
5+
schema: z.string(),
6+
});
7+
8+
export type ContentApiClient = {
9+
schema: string;
10+
query: QueryFn;
11+
};
12+
13+
export async function createContentApiClient(
14+
url: string,
15+
headers?: Record<string, string>
16+
): Promise<ContentApiClient> {
17+
const graphqlClient = new GraphQLClient({
18+
url,
19+
headers,
20+
// Content API provides schema string via `schema` query
21+
loadSchema: async ({ query }) => {
22+
const response = await query({ query: '{ schema }' });
23+
const { schema } = contentApiSchemaResponseSchema.parse(response);
24+
return schema;
25+
},
26+
});
27+
28+
const { source } = await graphqlClient.schemaLoaded;
29+
30+
return {
31+
schema: source,
32+
async query(request: GraphQLRequest) {
33+
return graphqlClient.query(request);
34+
},
35+
};
36+
}

0 commit comments

Comments
 (0)