Skip to content

feat(tools): add search_docs tool #90

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/mcp-server-supabase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@modelcontextprotocol/sdk": "^1.11.0",
"@supabase/mcp-utils": "0.2.1",
"common-tags": "^1.8.2",
"graphql": "^16.11.0",
"openapi-fetch": "^0.13.5",
"zod": "^3.24.1"
},
Expand Down
88 changes: 88 additions & 0 deletions packages/mcp-server-supabase/src/content-api/graphql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { stripIndent } from 'common-tags';
import { describe, expect, it } from 'vitest';
import { GraphQLClient } from './graphql.js';

describe('graphql client', () => {
it('should load schema', async () => {
const schema = stripIndent`
schema {
query: RootQueryType
}
type RootQueryType {
message: String!
}
`;

const graphqlClient = new GraphQLClient({
url: 'dummy-url',
loadSchema: async () => schema,
});

const { source } = await graphqlClient.schemaLoaded;

expect(source).toBe(schema);
});

it('should throw error if validation requested but loadSchema not provided', async () => {
const graphqlClient = new GraphQLClient({
url: 'dummy-url',
});

await expect(
graphqlClient.query(
{ query: '{ getHelloWorld }' },
{ validateSchema: true }
)
).rejects.toThrow('No schema loader provided');
});

it('should throw for invalid query regardless of schema', async () => {
const graphqlClient = new GraphQLClient({
url: 'dummy-url',
});

await expect(
graphqlClient.query({ query: 'invalid graphql query' })
).rejects.toThrow(
'Invalid GraphQL query: Syntax Error: Unexpected Name "invalid"'
);
});

it("should throw error if query doesn't match schema", async () => {
const schema = stripIndent`
schema {
query: RootQueryType
}
type RootQueryType {
message: String!
}
`;

const graphqlClient = new GraphQLClient({
url: 'dummy-url',
loadSchema: async () => schema,
});

await expect(
graphqlClient.query(
{ query: '{ invalidField }' },
{ validateSchema: true }
)
).rejects.toThrow(
'Invalid GraphQL query: Cannot query field "invalidField" on type "RootQueryType"'
);
});

it('bubbles up loadSchema errors', async () => {
const graphqlClient = new GraphQLClient({
url: 'dummy-url',
loadSchema: async () => {
throw new Error('Failed to load schema');
},
});

await expect(graphqlClient.schemaLoaded).rejects.toThrow(
'Failed to load schema'
);
});
});
224 changes: 224 additions & 0 deletions packages/mcp-server-supabase/src/content-api/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import {
buildSchema,
GraphQLError,
GraphQLSchema,
parse,
validate,
type DocumentNode,
} from 'graphql';
import { z } from 'zod';

export const graphqlRequestSchema = z.object({
query: z.string(),
variables: z.record(z.string(), z.unknown()).optional(),
});

export const graphqlResponseSuccessSchema = z.object({
data: z.record(z.string(), z.unknown()),
errors: z.undefined(),
});

export const graphqlErrorSchema = z.object({
message: z.string(),
locations: z.array(
z.object({
line: z.number(),
column: z.number(),
})
),
});

export const graphqlResponseErrorSchema = z.object({
data: z.undefined(),
errors: z.array(graphqlErrorSchema),
});

export const graphqlResponseSchema = z.union([
graphqlResponseSuccessSchema,
graphqlResponseErrorSchema,
]);

export type GraphQLRequest = z.infer<typeof graphqlRequestSchema>;
export type GraphQLResponse = z.infer<typeof graphqlResponseSchema>;

export type QueryFn = (
request: GraphQLRequest
) => Promise<Record<string, unknown>>;

export type QueryOptions = {
validateSchema?: boolean;
};

export type GraphQLClientOptions = {
/**
* The URL of the GraphQL endpoint.
*/
url: string;

/**
* A function that loads the GraphQL schema.
* This will be used for validating future queries.
*
* A `query` function is provided that can be used to
* execute GraphQL queries against the endpoint
* (e.g. if the API itself allows querying the schema).
*/
loadSchema?({ query }: { query: QueryFn }): Promise<string>;

/**
* Optional headers to include in the request.
*/
headers?: Record<string, string>;
};

export class GraphQLClient {
#url: string;
#headers: Record<string, string>;

/**
* A promise that resolves when the schema is loaded via
* the `loadSchema` function.
*
* Resolves to an object containing the raw schema source
* string and the parsed GraphQL schema.
*
* Rejects if no `loadSchema` function was provided to
* the constructor.
*/
schemaLoaded: Promise<{
/**
* The raw GraphQL schema string.
*/
source: string;

/**
* The parsed GraphQL schema.
*/
schema: GraphQLSchema;
}>;

/**
* Creates a new GraphQL client.
*/
constructor(options: GraphQLClientOptions) {
this.#url = options.url;
this.#headers = options.headers ?? {};

this.schemaLoaded =
options
.loadSchema?.({ query: this.#query.bind(this) })
.then((source) => ({
source,
schema: buildSchema(source),
})) ?? Promise.reject(new Error('No schema loader provided'));

// Prevent unhandled promise rejections
this.schemaLoaded.catch(() => {});
}

/**
* Executes a GraphQL query against the provided URL.
*/
async query(
request: GraphQLRequest,
options: QueryOptions = { validateSchema: true }
) {
try {
// Check that this is a valid GraphQL query
const documentNode = parse(request.query);

// Validate the query against the schema if requested
if (options.validateSchema) {
const { schema } = await this.schemaLoaded;
const errors = validate(schema, documentNode);
if (errors.length > 0) {
throw new Error(
`Invalid GraphQL query: ${errors.map((e) => e.message).join(', ')}`
);
}
}

return this.#query(request);
} catch (error) {
// Make it obvious that this is a GraphQL error
if (error instanceof GraphQLError) {
throw new Error(`Invalid GraphQL query: ${error.message}`);
}

throw error;
}
}

/**
* Executes a GraphQL query against the provided URL.
*
* Does not validate the query against the schema.
*/
async #query(request: GraphQLRequest) {
const { query, variables } = request;

const response = await fetch(this.#url, {
method: 'POST',
headers: {
...this.#headers,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
});

if (!response.ok) {
throw new Error(
`Failed to fetch Supabase Content API GraphQL schema: HTTP status ${response.status}`
);
}

const json = await response.json();

const { data, error } = graphqlResponseSchema.safeParse(json);

if (error) {
throw new Error(
`Failed to parse Supabase Content API response: ${error.message}`
);
}

if (data.errors) {
throw new Error(
`Supabase Content API GraphQL error: ${data.errors
.map(
(err) =>
`${err.message} (line ${err.locations[0]?.line ?? 'unknown'}, column ${err.locations[0]?.column ?? 'unknown'})`
)
.join(', ')}`
);
}

return data.data;
}
}

/**
* Extracts the fields from a GraphQL query document.
*/
export function getQueryFields(document: DocumentNode) {
return document.definitions
.filter((def) => def.kind === 'OperationDefinition')
.flatMap((def) => {
if (def.kind === 'OperationDefinition' && def.selectionSet) {
return def.selectionSet.selections
.filter((sel) => sel.kind === 'Field')
.map((sel) => {
if (sel.kind === 'Field') {
return sel.name.value;
}
return null;
})
.filter(Boolean);
}
return [];
});
}
36 changes: 36 additions & 0 deletions packages/mcp-server-supabase/src/content-api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { z } from 'zod';
import { GraphQLClient, type GraphQLRequest, type QueryFn } from './graphql.js';

const contentApiSchemaResponseSchema = z.object({
schema: z.string(),
});

export type ContentApiClient = {
schema: string;
query: QueryFn;
};

export async function createContentApiClient(
url: string,
headers?: Record<string, string>
): Promise<ContentApiClient> {
const graphqlClient = new GraphQLClient({
url,
headers,
// Content API provides schema string via `schema` query
loadSchema: async ({ query }) => {
const response = await query({ query: '{ schema }' });
const { schema } = contentApiSchemaResponseSchema.parse(response);
return schema;
},
});

const { source } = await graphqlClient.schemaLoaded;

return {
schema: source,
async query(request: GraphQLRequest) {
return graphqlClient.query(request);
},
};
}
Loading