Skip to content

Commit cfa3df7

Browse files
committed
feat(framework): add docs:local content API loop
1 parent ef04e57 commit cfa3df7

13 files changed

Lines changed: 700 additions & 30 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ evals/*/local/supabase/.branches/
1212
results/*/
1313
.sync-tmp/
1414

15+
/.local-docs/
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
2+
import { isAbsolute, join, resolve } from 'node:path';
3+
import { MCP_SERVER_VERSION } from '@supabase-evals/core';
4+
5+
/** First stable MCP release containing supabase/mcp#343. */
6+
export const CONTENT_API_FLAG_MIN_VERSION = '0.10.0';
7+
8+
const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
9+
10+
export function supportsContentApiFlag(version: string): boolean {
11+
const parse = (value: string, label: string) => {
12+
const match = VERSION_RE.exec(value.trim());
13+
if (!match) {
14+
throw new Error(
15+
`${label} is not a MAJOR.MINOR.PATCH version: ${JSON.stringify(value)}`
16+
);
17+
}
18+
return {
19+
numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
20+
prerelease: match[4] !== undefined,
21+
};
22+
};
23+
const current = parse(version, 'mcp server version');
24+
const minimum = parse(
25+
CONTENT_API_FLAG_MIN_VERSION,
26+
'CONTENT_API_FLAG_MIN_VERSION'
27+
);
28+
if (current.prerelease) return false;
29+
for (let index = 0; index < 3; index++) {
30+
if (current.numbers[index] !== minimum.numbers[index]) {
31+
return current.numbers[index] > minimum.numbers[index];
32+
}
33+
}
34+
return true;
35+
}
36+
37+
export function resolveMcpServerPath(raw: string): string {
38+
let path = isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
39+
if (!existsSync(path)) throw new Error(`--mcp path does not exist: ${path}`);
40+
const packageDir = join(path, 'packages', 'mcp-server-supabase');
41+
if (existsSync(packageDir)) path = packageDir;
42+
if (!existsSync(join(path, 'dist', 'transports', 'stdio.js'))) {
43+
throw new Error(
44+
`no built server at ${path} (dist/transports/stdio.js missing) — build it first:\n pnpm install && pnpm build`
45+
);
46+
}
47+
try {
48+
const localVersion = JSON.parse(
49+
readFileSync(join(path, 'package.json'), 'utf8')
50+
).version;
51+
if (localVersion && localVersion !== MCP_SERVER_VERSION) {
52+
console.error(
53+
`note: local mcp build is v${localVersion}; the harness fixture (platform-lite) tracks the v${MCP_SERVER_VERSION} pin — endpoint drift is possible`
54+
);
55+
}
56+
} catch {
57+
// An unversioned checkout is valid when it has the expected built entry.
58+
}
59+
return realpathSync(path);
60+
}
61+
62+
const CONTENT_API_OPTION = /[,{]\s*(["'])(?:--)?content-api-url\1\s*:/;
63+
64+
export function validateContentApi(
65+
contentApiUrl: string,
66+
mcpServerPath?: string
67+
) {
68+
if (!mcpServerPath) {
69+
if (supportsContentApiFlag(MCP_SERVER_VERSION)) return;
70+
throw new Error(
71+
`--content-api needs --mcp <path>: the harness launches the pinned v${MCP_SERVER_VERSION} package, which has no --content-api-url flag (added in v${CONTENT_API_FLAG_MIN_VERSION} via supabase/mcp#343), so search_docs would query production docs while the receipt claims ${contentApiUrl}`
72+
);
73+
}
74+
const stdio = join(mcpServerPath, 'dist', 'transports', 'stdio.js');
75+
if (!CONTENT_API_OPTION.test(readFileSync(stdio, 'utf8'))) {
76+
throw new Error(
77+
`the mcp build at ${mcpServerPath} has no --content-api-url flag (predates supabase/mcp#343) — search_docs would query production docs, not ${contentApiUrl}`
78+
);
79+
}
80+
}

apps/framework/harness/run-eval.ts

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
statSync,
1313
writeFileSync,
1414
} from 'node:fs';
15-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
15+
import { dirname, join, relative } from 'node:path';
1616
import { fileURLToPath, pathToFileURL } from 'node:url';
1717
import { jsonSchema, tool, type ToolSet } from 'ai';
1818
import { parseEvalMarkdown } from '@supabase-evals/core/eval-markdown';
@@ -32,6 +32,7 @@ import {
3232
} from '../lib/cli-args.js';
3333
import { bootPlatformBackend } from './platform-backend.js';
3434
import { viteBuild, vitestRun } from './project-runner.js';
35+
import { resolveMcpServerPath, validateContentApi } from './mcp-launch.js';
3536
import {
3637
buildDocsResult,
3738
buildSkillResult,
@@ -73,6 +74,7 @@ const SMOKE = args.has('--smoke');
7374
const DRY = args.has('--dry');
7475
const STRICT = args.has('--strict');
7576
const MCP_PATH = readFlag(rawArgs, 'mcp');
77+
const CONTENT_API_URL = readFlag(rawArgs, 'content-api');
7678
const EXPERIMENT_FILTERS = readRepeatedFlag(rawArgs, 'experiment').map(
7779
normalizeExperimentName
7880
);
@@ -641,6 +643,7 @@ type Provenance = {
641643
generatedAt: string;
642644
host: { sha?: string; branch?: string; dirtyFiles: number };
643645
mcpOverride?: { path: string; sha?: string; dirtyFiles?: number };
646+
contentApiUrl?: string;
644647
platform: string;
645648
};
646649

@@ -654,7 +657,10 @@ function tryGit(args: string[], cwd: string): string | undefined {
654657
}
655658
}
656659

657-
function collectProvenance(mcpPath?: string): Provenance {
660+
function collectProvenance(
661+
mcpPath?: string,
662+
contentApiUrl?: string
663+
): Provenance {
658664
const dirty = (cwd: string) =>
659665
(tryGit(['status', '--porcelain'], cwd) ?? '').split('\n').filter(Boolean)
660666
.length;
@@ -675,34 +681,10 @@ function collectProvenance(mcpPath?: string): Provenance {
675681
dirtyFiles: repository ? dirty(repository) : undefined,
676682
};
677683
}
684+
if (contentApiUrl) provenance.contentApiUrl = contentApiUrl;
678685
return provenance;
679686
}
680687

681-
function resolveMcpServerPath(raw: string): string {
682-
let path = isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
683-
if (!existsSync(path)) throw new Error(`--mcp path does not exist: ${path}`);
684-
const packageDir = join(path, 'packages', 'mcp-server-supabase');
685-
if (existsSync(packageDir)) path = packageDir;
686-
if (!existsSync(join(path, 'dist', 'transports', 'stdio.js'))) {
687-
throw new Error(
688-
`no built server at ${path} (dist/transports/stdio.js missing) — build it first:\n pnpm install && pnpm build`
689-
);
690-
}
691-
try {
692-
const localVersion = JSON.parse(
693-
readFileSync(join(path, 'package.json'), 'utf8')
694-
).version;
695-
if (localVersion && localVersion !== MCP_SERVER_VERSION) {
696-
console.error(
697-
`note: local mcp build is v${localVersion}; the harness fixture (platform-lite) tracks the v${MCP_SERVER_VERSION} pin — endpoint drift is possible`
698-
);
699-
}
700-
} catch {
701-
// An unversioned checkout is valid when it has the expected built entry.
702-
}
703-
return realpathSync(path);
704-
}
705-
706688
function validateJudgeKeys(evals: readonly EvalManifest[]) {
707689
if (process.env.OPENAI_API_KEY || DRY) return;
708690
const judged = evals
@@ -781,6 +763,10 @@ async function main() {
781763

782764
const mcpPath = MCP_PATH ? resolveMcpServerPath(MCP_PATH) : undefined;
783765
if (mcpPath) process.env.SUPABASE_MCP_SERVER_PATH = mcpPath;
766+
if (CONTENT_API_URL) {
767+
validateContentApi(CONTENT_API_URL, mcpPath);
768+
process.env.SUPABASE_CONTENT_API_URL = CONTENT_API_URL;
769+
}
784770

785771
const allExperiments = await loadExperiments();
786772
if (EXPERIMENT_FILTERS.length > 0) {
@@ -916,7 +902,7 @@ async function main() {
916902

917903
validateJudgeKeys([...new Set(allWork.map(({ ev }) => ev))]);
918904
if (!DEBUG) console.error = () => undefined;
919-
const provenance = collectProvenance(mcpPath);
905+
const provenance = collectProvenance(mcpPath, CONTENT_API_URL);
920906

921907
let localStackTurn = Promise.resolve();
922908
const errored: Error[] = [];

apps/framework/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"eval": "node --env-file-if-exists=../../.env --import tsx/esm harness/run-eval.ts",
99
"eval:dry": "node --env-file-if-exists=../../.env --import tsx/esm harness/run-eval.ts --dry",
1010
"eval:smoke": "node --env-file-if-exists=../../.env --import tsx/esm harness/run-eval.ts --smoke",
11+
"docs:local": "node --env-file-if-exists=../../.env --import tsx/esm scripts/local-docs.ts",
1112
"eval:vercel": "node --env-file=../../.env --import tsx/esm scripts/run-vercel-evals.ts",
1213
"typecheck": "tsc --noEmit",
1314
"test:framework": "node --env-file-if-exists=../../.env --import tsx/esm scripts/smoke-framework.ts",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// fallow-ignore-file unused-file -- loaded at runtime (spawned/injected by local-docs.ts), never statically imported
2+
/**
3+
* Standalone docs content GraphQL API for `search_docs`.
4+
*
5+
* Serves the docs app's own route handler (apps/docs/app/api/graphql/route.ts
6+
* in a supabase/supabase checkout) over plain node:http — no Next server.
7+
* Launched by `pnpm docs:local api` with the docs checkout's tsx so the
8+
* route's TS + tsconfig conditions resolve; DOCS_ROUTE_PATH points at the
9+
* checkout, PORT picks the listen port.
10+
*/
11+
import { createServer } from 'node:http';
12+
import { pathToFileURL } from 'node:url';
13+
14+
const routePath = process.env.DOCS_ROUTE_PATH;
15+
if (!routePath) {
16+
console.error(
17+
'DOCS_ROUTE_PATH not set — run this through `pnpm docs:local api`'
18+
);
19+
process.exit(1);
20+
}
21+
// The docs checkout location is user-supplied at runtime; a static import
22+
// cannot name it.
23+
const route = await import(pathToFileURL(routePath).href);
24+
const handlers: Record<string, (req: Request) => Promise<Response>> = {
25+
GET: route.GET,
26+
OPTIONS: route.OPTIONS,
27+
POST: route.POST,
28+
};
29+
const port = Number(process.env.PORT ?? 3001);
30+
31+
createServer(async (incoming, outgoing) => {
32+
const url = new URL(
33+
incoming.url ?? '/',
34+
`http://${incoming.headers.host ?? `127.0.0.1:${port}`}`
35+
);
36+
const handler = handlers[incoming.method ?? ''];
37+
if (url.pathname !== '/docs/api/graphql' || !handler) {
38+
outgoing.writeHead(404).end();
39+
return;
40+
}
41+
42+
const headers = new Headers();
43+
for (const [name, value] of Object.entries(incoming.headers)) {
44+
if (Array.isArray(value))
45+
for (const item of value) headers.append(name, item);
46+
else if (value !== undefined) headers.set(name, value);
47+
}
48+
49+
const chunks: Buffer[] = [];
50+
for await (const chunk of incoming) chunks.push(Buffer.from(chunk));
51+
const body =
52+
incoming.method === 'GET' || incoming.method === 'HEAD'
53+
? undefined
54+
: Buffer.concat(chunks).toString('utf8');
55+
const response = await handler(
56+
new Request(url, { method: incoming.method, headers, body })
57+
);
58+
59+
outgoing.writeHead(
60+
response.status,
61+
Object.fromEntries(response.headers.entries())
62+
);
63+
outgoing.end(Buffer.from(await response.arrayBuffer()));
64+
// Bind every interface, advertise loopback — same rule as platform-lite in
65+
// tools mode (see run-eval.ts: sandboxed CLI agents run their MCP servers
66+
// INSIDE the container and reach host-side services via
67+
// host.docker.internal, which arrives on the host's bridge interface, not
68+
// loopback; a 127.0.0.1-only listener refuses those connections).
69+
}).listen(port, '0.0.0.0', () => {
70+
console.log(`Docs content API: http://127.0.0.1:${port}/docs/api/graphql`);
71+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// fallow-ignore-file unused-file -- registered at runtime by sentry-stub-register.mjs via module.register()
2+
// Loader-thread resolve hook: '@sentry/nextjs' -> the no-op stub.
3+
const stubUrl = new URL('./sentry-stub.mjs', import.meta.url).href;
4+
5+
// fallow-ignore-next-line unused-export -- Node loader-hook contract: the module system calls `resolve`
6+
export async function resolve(specifier, context, next) {
7+
if (specifier === '@sentry/nextjs') {
8+
return { url: stubUrl, shortCircuit: true };
9+
}
10+
return next(specifier, context);
11+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// fallow-ignore-file unused-file -- loaded at runtime (spawned/injected by local-docs.ts), never statically imported
2+
// Registers a resolve hook that short-circuits '@sentry/nextjs' to the local
3+
// no-op stub. Injected via NODE_OPTIONS from `pnpm local docs api`; chains with tsx's
4+
// own hooks (ours only intercepts the one specifier). Uses module.register()
5+
// (Node 20.6+) rather than registerHooks() (22.15+) — mise pins node "22",
6+
// which an older 22.x install satisfies.
7+
import { register } from 'node:module';
8+
9+
register('./sentry-stub-loader.mjs', import.meta.url);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// fallow-ignore-file unused-file -- loaded at runtime (spawned/injected by local-docs.ts), never statically imported
2+
// No-op @sentry/nextjs stand-in for the standalone docs content API.
3+
// The route handler calls Sentry.captureException/flush; under plain tsx
4+
// (outside Next's Sentry instrumentation) the real package's ESM build
5+
// resolves without those functions and every request crashes. A local dev
6+
// adapter has no business sending telemetry anyway. Wired up by
7+
// sentry-stub-register.mjs (see local-docs.ts).
8+
export const captureException = () => '';
9+
export const flush = async () => true;

0 commit comments

Comments
 (0)