Files
supabase/apps/studio/lib/ai/tools/search-docs-tool.ts
T
Pedro RodriguesandClaude Sonnet 5 22d7bc0cfd feat(studio-evals): custom search_docs tool for the eval harness (no token / no PAT) (#50092)
- Eval harness's only live tool, `search_docs`, no longer needs the
in-process MCP client or its dummy token — it now calls the public docs
GraphQL API (`https://supabase.com/docs/api/graphql`) directly. Low risk
as this is an eval-harness change only. Production assistant path
(`mcp-tools.ts`) untouched.

**Update:** per [@mattrossman's
review](https://github.com/supabase/supabase/pull/50092#discussion_r3980396341),
the eval tool's description embeds the Content API's own GraphQL schema
(fetched via a `{ schema }` query and minified with `gqlmin`), mirroring
how `@supabase/mcp-server-supabase`'s `docs-tools.ts`/`loadSchema`
populates production's `search_docs` description. Without it, the model
had no schema to work from and issued malformed queries, which caused
the 218 `search_docs` errors and the -25pp Docs Faithfulness regression
in the first eval run on this PR. Schema loading is required:
`createSearchDocsTool()` rejects if the schema fetch fails, so preflight
and the gated eval job fail loudly instead of producing untrustworthy
fallback results. `createSearchDocsTool` is async because the `ai`
package's `tool()` only accepts a plain string `description`, unlike the
MCP SDK's async description support; both callers (`getMockTools`,
`evals/preflight.ts`) await it. `gqlmin` is a direct `apps/studio`
dependency and was already transitive via
`@supabase/mcp-server-supabase`.

### Verification
- `pnpm -C apps/studio exec -- tsc --noEmit` reaches the compiler; it
reports only the pre-existing unrelated
`packages/ui-patterns/src/McpUrlBuilder/components/InstructionBlocks.tsx`
`StaticImageData` error.
- `pnpm -C apps/studio exec -- vitest run
lib/ai/tools/mock-tools.test.ts lib/ai/tools/mcp-tools.test.ts` — 21/21
passed.
- `pnpm exec tsx evals/preflight.ts` — live docs API schema fetch and
search_docs call passed.
- `NEXT_PUBLIC_CONTENT_API_URL=http://127.0.0.1:1/graphql pnpm -C
apps/studio exec -- tsx evals/preflight.ts` — failed fast as expected,
proving schema/API failures gate evals.
- Fresh `run-evals` pass: Docs Faithfulness 55.7% (0pp), with no
systemic `search_docs` regression.

Risk: eval-harness-only; schema/API outage now fails the eval job before
scoring rather than allowing fallback descriptions.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added documentation search powered by the public Supabase
documentation GraphQL API.
* Documentation search results now include live schema information and
clearer error handling for failed or invalid requests.

* **Bug Fixes**
* Improved evaluation tooling reliability by removing unnecessary
connection-abort behavior.
* Updated validation to detect missing search tools and malformed
documentation responses.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:51:14 +02:00

118 lines
4.3 KiB
TypeScript

import { tool, type Tool } from 'ai'
import gqlmin from 'gqlmin'
import { z } from 'zod'
const searchDocsInputSchema = z.object({
graphql_query: z.string().describe('A valid GraphQL query against the Supabase docs API.'),
})
const CONTENT_API_URL =
process.env.NEXT_PUBLIC_CONTENT_API_URL ?? 'https://supabase.com/docs/api/graphql'
/**
* Sends a GraphQL query to the public Supabase docs API.
*
* Mirrors the @supabase/mcp-server-supabase content API client: GET
* `<url>?query=<encoded>` with `Accept: application/json`, returning the
* GraphQL envelope's `data` field.
*/
async function queryContentApiGraphQL(graphqlQuery: string): Promise<unknown> {
const url = new URL(CONTENT_API_URL)
url.searchParams.set('query', graphqlQuery)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'User-Agent': 'supabase-studio-evals',
},
// A stalled connection or response body would otherwise hang getMockTools
// and preflight indefinitely.
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) {
throw new Error(`Failed to fetch Supabase Content API: HTTP status ${response.status}`)
}
const body = (await response.json()) as {
data?: unknown
errors?: Array<{ message: string; locations?: Array<{ line: number; column: number }> }>
}
if (body.errors?.length) {
throw new Error(
`Supabase Content API GraphQL error: ${body.errors
.map((error) => {
const location = error.locations?.[0]
return `${error.message} (line ${location?.line ?? 'unknown'}, column ${location?.column ?? 'unknown'})`
})
.join(', ')}`
)
}
if (!body.data) {
throw new Error('Supabase Content API returned no data')
}
return body.data
}
const STATIC_DESCRIPTION =
'Search the Supabase documentation using GraphQL. Must be a valid GraphQL query. ' +
'You should default to calling this even if you think you already know the answer, ' +
'since the documentation is always being updated.'
/**
* Fetches and minifies the Content API's own GraphQL schema (via the `{
* schema }` query it exposes), mirroring
* `@supabase/mcp-server-supabase`'s `loadSchema` so the eval tool's
* description is as close as practical to what production Assistant sees.
*/
async function loadContentApiSchema(): Promise<string> {
const data = (await queryContentApiGraphQL('{ schema }')) as { schema?: unknown }
if (typeof data.schema !== 'string' || !data.schema) {
throw new Error('Supabase Content API `{ schema }` query returned no schema string')
}
return gqlmin(data.schema)
}
/**
* Builds the tool description with the live GraphQL schema embedded, so the
* model has the same schema context production's `search_docs` gives it (see
* `@supabase/mcp-server-supabase`'s `docs-tools.ts`).
*
* Schema loading is required: running an eval without the schema makes the
* model's GraphQL queries untrustworthy and can hide a real docs-search
* regression behind fallback results.
*/
async function buildDescription(): Promise<string> {
const schema = await loadContentApiSchema()
return `${STATIC_DESCRIPTION}\n\nBelow is the GraphQL schema for this tool:\n\n${schema}`
}
/**
* Self-contained `search_docs` tool for the eval harness: calls the public
* docs GraphQL API directly, so no MCP client or access token is needed.
* Emits the MCP text-content shape the scorers parse
* (mcpTextContentSpanOutputSchema / docsFaithfulnessScorer):
* `{ content: [{ type: 'text', text: JSON.stringify({ result }) }] }`.
*
* `description` is resolved before construction (the `ai` package's `tool()`
* only accepts a plain string, not an async function like the MCP SDK's
* `docs-tools.ts` uses), so this factory is async.
*/
export type SearchDocsTool = Tool<
z.infer<typeof searchDocsInputSchema>,
{ content: Array<{ type: 'text'; text: string }> }
>
export async function createSearchDocsTool(): Promise<SearchDocsTool> {
const description = await buildDescription()
return tool({
description,
inputSchema: searchDocsInputSchema,
execute: async ({ graphql_query }: { graphql_query: string }) => {
const result = await queryContentApiGraphQL(graphql_query)
return { content: [{ type: 'text' as const, text: JSON.stringify({ result }) }] }
},
})
}