/** * Shared fixtures for the MCP end-to-end tests. They run against a live dev server (default * http://localhost:3000, override with MCP_BASE_URL) and seed users, tokens and sessions straight into * that server's local D1 and KV with wrangler. Set MCP_E2E_ROOT to the checkout that server runs from * (default: this checkout). */ import { createHash, randomBytes } from "node:crypto"; import { scopedSqlRows as scopedRows } from "./scoped-sql"; import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens, } from "@modelcontextprotocol/client"; export const BASE = process.env.MCP_BASE_URL ?? "http://localhost:3000"; export const MODERN = "2026-07-28"; /** Checkout whose `vite dev` serves BASE: wrangler seeds that checkout's local D1 and KV. */ const ROOT = process.env.MCP_E2E_ROOT ?? new URL("..", import.meta.url).pathname; const REDIRECT = "http://127.0.0.1:8976/callback"; const SESSION_COOKIE = "__Host-gitfor_session"; const SSE_DATA = "data: "; const SSE_ID = "id: "; const HTTP_CREATED = 201; export type World = { suffix: string; userId: string; owner: string; repo: string; otherRepo: string; repoId: string; classicToken: string; sessionCookie: string; }; /** The parts of JSON-RPC replies the raw-HTTP tests look at. */ export type RpcReply = { result?: { structuredContent?: { pushed?: boolean }; tools?: unknown[]; resultType?: string; _meta?: Record; }; error?: { code: number }; }; export type ToolResult = { isError?: boolean; structuredContent?: Record; content: Array<{ type: string; text?: string }>; }; export function sha256Hex(value: string): string { return createHash("sha256").update(value).digest("hex"); } function wrangler(args: string[]): string { const result = Bun.spawnSync(["bunx", "wrangler", ...args], { cwd: ROOT, stderr: "pipe", stdout: "pipe" }); if (result.exitCode !== 0) throw new Error(`wrangler ${args.join(" ")} failed: ${result.stderr.toString()}`); return result.stdout.toString(); } export function sql(command: string): void { wrangler(["d1", "execute", "gitfor", "--local", "--command", command]); } export function sqlRows(command: string): T[] { const out: Array<{ results?: T[] }> = JSON.parse( wrangler(["d1", "execute", "gitfor", "--local", "--json", "--command", command]), ); return out[0]?.results ?? []; } export function scopedSqlRows(scope: "repo" | "account", id: string, command: string): T[] { return scopedRows(BASE, scope, id, command); } export function quote(value: string | number | null): string { if (value === null) return "NULL"; return typeof value === "number" ? String(value) : `'${value.replaceAll("'", "''")}'`; } export async function body(response: Response): Promise { const parsed: unknown = await response.json(); return parsed as T; } /** Classic tokens no longer exist (migration 0026): a "classic" seed is a fine-grained token with every area. */ const FULL_ACCESS = { contents: "write", issues: "write", pulls: "write", actions: "write", admin: "write" }; /** Inserts a personal token row directly; `permissions` null makes a full-access token. */ export function seedToken( world: Pick, name: string, permissions: Record | null, repoIds: string[] = [], ): string { const token = `gitfor_pat_${randomBytes(20).toString("hex")}`; const id = `tok${randomBytes(6).toString("hex")}`; const hash = sha256Hex(token); scopedSqlRows( "account", world.userId, `INSERT INTO access_tokens (id, user_id, name, token_hash, token_prefix, scopes, permissions, all_repos, expires_at, created_at) VALUES (${quote(id)}, ${quote(world.userId)}, ${quote(name)}, ${quote(hash)}, 'gitfor_pat_x', 'repo', ${quote(JSON.stringify(permissions ?? FULL_ACCESS))}, ${repoIds.length ? 0 : 1}, NULL, ${Date.now()})`, ); for (const repoId of repoIds) { scopedSqlRows( "account", world.userId, `INSERT INTO access_token_repos (token_id, repo_id) VALUES (${quote(id)}, ${quote(repoId)})`, ); } // Tokens are found by hash or id through the D1 locator directory. sql( `INSERT INTO scope_locators (kind, key, scope_id) VALUES ('access_token', ${quote(hash)}, ${quote(world.userId)}), ('access_token_id', ${quote(id)}, ${quote(world.userId)})`, ); return token; } export function api(world: Pick, path: string, init: RequestInit = {}): Promise { return fetch(`${BASE}/api/v1${path}`, { ...init, headers: { authorization: `Bearer ${world.classicToken}`, "content-type": "application/json", ...init.headers }, }); } async function createRepo(world: Pick, name: string): Promise { const response = await api(world, "/repos", { method: "POST", body: JSON.stringify({ name, private: true }) }); if (response.status !== HTTP_CREATED) throw new Error(`create repo failed: ${response.status} ${await response.text()}`); const repo = await body<{ id: string }>(response); const seeded = await api(world, `/repos/${world.owner}/${name}/contents/README.md`, { method: "PUT", body: JSON.stringify({ branch: "main", message: "Seed", content: btoa("# seeded\n") }), }); if (!seeded.ok) throw new Error(`seed failed: ${seeded.status} ${await seeded.text()}`); return repo.id; } /** A fresh user with a browser session, a classic token and two private seeded repositories. */ export async function seedWorld(): Promise { const suffix = randomBytes(4).toString("hex"); const userId = `mcpu${suffix}`; const owner = `mcp-alice-${suffix}`; const now = Date.now(); sql( `INSERT INTO accounts (id, name, kind, display_name, created_at) VALUES (${quote(userId)}, ${quote(owner)}, 'user', 'Alice MCP', ${now}); INSERT INTO users (account_id, email, is_admin, email_verified_at) VALUES (${quote(userId)}, ${quote(`${owner}@example.test`)}, 0, ${now});`, ); const classicToken = seedToken({ userId }, "e2e classic", null); const session = `sess_${randomBytes(20).toString("hex")}`; const record = JSON.stringify({ userId, createdAt: now }); wrangler(["kv", "key", "put", "--local", "--binding", "KV", `session:${sha256Hex(session)}`, record]); const partial = { owner, classicToken }; const repo = `mcp-e2e-${suffix}`; const otherRepo = `mcp-other-${suffix}`; const [repoId] = await Promise.all([createRepo(partial, repo), createRepo(partial, otherRepo)]); return { suffix, userId, owner, repo, otherRepo, repoId, classicToken, sessionCookie: `${SESSION_COOKIE}=${session}`, }; } /** Registers a named agent through the REST API and mints an agent-bound token for one repository. */ export async function agentToken( world: World, name: string, permissions: Record, forgeAccess = false, ): Promise { const created = await api(world, "/user/agents", { method: "POST", body: JSON.stringify({ name }) }); if (created.status !== HTTP_CREATED) throw new Error(`agent create failed: ${created.status} ${await created.text()}`); const agent = await body<{ id: string }>(created); const minted = await api(world, `/user/agents/${agent.id}/tokens`, { method: "POST", body: JSON.stringify({ repos: [`${world.owner}/${world.repo}`], permissions, forge_access: forgeAccess }), }); if (minted.status !== HTTP_CREATED) throw new Error(`agent token failed: ${minted.status} ${await minted.text()}`); return (await body<{ token: string }>(minted)).token; } export async function teardown(world: World): Promise { await Promise.all( [world.repo, world.otherRepo].map((name) => api(world, `/repos/${world.owner}/${name}`, { method: "DELETE" })), ); } export function toolText(result: ToolResult): string { return result.content.find((block) => block.type === "text")?.text ?? ""; } /** A headless agent authenticating with a personal token and naming itself via X-Gitfor-Agent. */ export function patClient(token: string, agent: string) { const client = new Client({ name: agent, version: "1.0.0" }, { versionNegotiation: { mode: { pin: MODERN } } }); const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), { requestInit: { headers: { authorization: `Bearer ${token}`, "x-gitfor-agent": agent } }, }); return { client, transport }; } export async function call(client: Client, name: string, args: Record): Promise { const result: unknown = await client.callTool({ name, arguments: args }); return result as ToolResult; } export class TestOAuthProvider implements OAuthClientProvider { info: OAuthClientInformationMixed | undefined; saved: OAuthTokens | undefined; verifier = ""; discovery: unknown; authorizationUrl: URL | null = null; constructor(private readonly scope: string) {} get redirectUrl() { return REDIRECT; } get clientMetadata(): OAuthClientMetadata { return { client_name: "E2E Agent", redirect_uris: [REDIRECT], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", scope: this.scope, }; } clientInformation() { return this.info; } saveClientInformation(info: OAuthClientInformationMixed) { this.info = info; } tokens() { return this.saved; } saveTokens(tokens: OAuthTokens) { this.saved = tokens; } redirectToAuthorization(url: URL) { this.authorizationUrl = url; } saveCodeVerifier(verifier: string) { this.verifier = verifier; } codeVerifier() { return this.verifier; } saveDiscoveryState(state: unknown) { this.discovery = state; } discoveryState() { return this.discovery as never; } } /** Plays the browser: loads the consent screen with the user's session and approves it. */ export async function approveConsent(world: World, url: URL) { const page = await (await fetch(url, { headers: { cookie: world.sessionCookie } })).text(); const requestId = /name="request_id" value="([^"]+)"/.exec(page)?.[1]; if (!requestId) throw new Error(`No consent form: ${page.slice(0, 500)}`); const response = await fetch(`${BASE}/oauth/authorize`, { method: "POST", redirect: "manual", headers: { cookie: world.sessionCookie, origin: BASE, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ request_id: requestId, decision: "approve" }), }); const location = new URL(response.headers.get("location") ?? ""); return { page, code: location.searchParams.get("code") ?? "", iss: location.searchParams.get("iss") }; } function parseFrame(raw: string): { id: string | null; data: string } { const lines = raw.split("\n"); const data = lines .filter((line) => line.startsWith(SSE_DATA)) .map((line) => line.slice(SSE_DATA.length)) .join("\n"); const id = lines.find((line) => line.startsWith(SSE_ID))?.slice(SSE_ID.length) ?? null; return { id, data }; } /** Reads SSE frames until `until` matches one or the timeout passes, then cancels the stream. */ export async function readSse(response: Response, until: (data: string) => boolean, timeoutMs: number) { const frames: Array<{ id: string | null; data: string }> = []; if (!response.body) return frames; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const chunk = await Promise.race([reader.read(), Bun.sleep(deadline - Date.now()).then(() => null)]); if (!chunk || chunk.done) break; buffer += decoder.decode(chunk.value, { stream: true }); const parts = buffer.split("\n\n"); buffer = parts.pop() ?? ""; for (const part of parts) { const frame = parseFrame(part); if (frame.data) frames.push(frame); } if (frames.some((frame) => until(frame.data))) break; } await reader.cancel().catch(() => undefined); return frames; }