Public: anyone can read this; the crew can change it.

The Tesla test: an MCP client connects with OAuth from a bare 401 and steers, in one journey test #12482

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12464-the-tesla-test-an-mcp-client-connects-wi → main 1 commit fork

  • Merged. scout/12464-the-tesla-test-an-mcp-client-connects-wi was merged into main as 200d994.

Review

Sign in to review this change.
1 file changed +190 −0
test/tesla-journey.test.ts added +190
@@ -0,0 +1,190 @@
1 /**
2 * The Tesla test, end to end (#12464): an MCP client that knows only the
3 * forge's address calls /mcp, is answered 401 with a pointer, reads both
4 * discovery documents, registers itself, is authorized by a signed-in person
5 * (PKCE), exchanges the code, opens a session, lists the tools, tells an app
6 * something and finds the issue that made. A revoked token and a replayed
7 * code are refused, each with the one next thing to do.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { newApp } from "../src/actions/apps.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { createUser } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 const REDIRECT = "http://127.0.0.1:9/callback";
23 const verifier = "t".repeat(64);
24 const challenge = new Bun.CryptoHasher("sha256").update(verifier).digest("base64url");
25
26 describe("the Tesla test: any MCP client connects with OAuth and steers", () => {
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let base: string;
31 let cookie = "";
32 let token = "";
33 let clientId = "";
34 let code = "";
35 const rpc = (method: string, params: unknown, bearer?: string, id = 1) =>
36 fetch(`${base}/mcp`, {
37 method: "POST",
38 headers: {
39 "content-type": "application/json",
40 accept: "application/json, text/event-stream",
41 ...(bearer === undefined ? {} : { authorization: `Bearer ${bearer}` }),
42 },
43 body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
44 });
45 const call = async (name: string, args: unknown, bearer = token) => {
46 const out = (await (await rpc("tools/call", { name, arguments: args }, bearer)).json()) as any;
47 return out.result.content[0].text as string;
48 };
49 const exchange = (c: string) =>
50 fetch(`${base}/oauth/token`, {
51 method: "POST",
52 body: new URLSearchParams({
53 grant_type: "authorization_code",
54 client_id: clientId,
55 redirect_uri: REDIRECT,
56 code_verifier: verifier,
57 code: c,
58 }),
59 });
60 const authorize = async (name: string) => {
61 const form = new URLSearchParams({
62 response_type: "code",
63 client_id: clientId,
64 redirect_uri: REDIRECT,
65 code_challenge: challenge,
66 code_challenge_method: "S256",
67 state: "s1",
68 });
69 const page = await fetch(`${base}/oauth/authorize?${form}`, { headers: { cookie } });
70 expect(page.status).toBe(200);
71 const res = await fetch(`${base}/oauth/authorize`, {
72 method: "POST",
73 redirect: "manual",
74 headers: { cookie, origin: base },
75 body: new URLSearchParams({
76 ...Object.fromEntries(form),
77 decision: "allow",
78 grant: "rw",
79 name,
80 lifetime: "never",
81 }),
82 });
83 expect(res.status).toBe(303);
84 const to = new URL(res.headers.get("location")!);
85 expect(to.searchParams.get("state")).toBe("s1");
86 return to.searchParams.get("code")!;
87 };
88
89 beforeAll(async () => {
90 dataDir = await mkdtemp(join(tmpdir(), "forge-tesla-"));
91 app = createApp({ dataDir, port: 0 });
92 servers = await serve(app);
93 base = servers.url;
94 const made = await createUser(app.db, {
95 origin: "seed",
96 trusted: true,
97 username: "alice",
98 email: "[email protected]",
99 password: "a-good-passphrase",
100 adminIfFirst: true,
101 });
102 if (!made.ok) throw new Error(made.error);
103 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
104 const a = await newApp(app, made.value, { name: "notes", description: "Words", visibility: "public" });
105 if (!a.ok) throw new Error(String(a.error));
106 }, 60_000);
107 afterAll(async () => {
108 servers.stop();
109 await rm(dataDir, { recursive: true, force: true });
110 });
111
112 test("a bare call is answered 401 pointing at the protected-resource document", async () => {
113 const res = await rpc("initialize", {});
114 expect(res.status).toBe(401);
115 const challenge = res.headers.get("www-authenticate") ?? "";
116 expect(challenge).toMatch(/^Bearer /);
117 expect(challenge).not.toContain("invalid_token");
118 const url = /resource_metadata="([^"]+)"/.exec(challenge)?.[1];
119 expect(url).toBe(`${base}/.well-known/oauth-protected-resource`);
120 });
121
122 test("the discovery documents agree with each other and with the routes", async () => {
123 const resource = (await (await fetch(`${base}/.well-known/oauth-protected-resource`)).json()) as any;
124 expect(resource.resource).toBe(`${base}/mcp`);
125 const [issuer] = resource.authorization_servers as string[];
126 expect(issuer).toBe(base);
127 const res = await fetch(`${issuer}/.well-known/oauth-authorization-server`);
128 expect(res.status).toBe(200);
129 const meta = (await res.json()) as any;
130 expect(meta.issuer).toBe(issuer);
131 expect(meta.code_challenge_methods_supported).toContain("S256");
132 expect(meta.grant_types_supported).toContain("authorization_code");
133 for (const s of resource.scopes_supported) expect(meta.scopes_supported).toContain(s);
134 // Every endpoint named is a route that answers (not a 404).
135 for (const key of ["authorization_endpoint", "token_endpoint", "registration_endpoint", "revocation_endpoint"]) {
136 expect(String(meta[key]).startsWith(`${base}/`)).toBe(true);
137 const probe = await fetch(meta[key], { method: key === "authorization_endpoint" ? "GET" : "POST", redirect: "manual" });
138 expect(probe.status).not.toBe(404);
139 expect(probe.status).not.toBe(405);
140 }
141 // The registration step uses the advertised endpoint.
142 const reg = await fetch(meta.registration_endpoint, {
143 method: "POST",
144 headers: { "content-type": "application/json" },
145 body: JSON.stringify({ client_name: "Tesla", redirect_uris: [REDIRECT] }),
146 });
147 expect(reg.status).toBe(201);
148 clientId = ((await reg.json()) as any).client_id;
149 expect(clientId).toBeTruthy();
150 });
151
152 test("the person authorizes, the code is exchanged, and the client steers", async () => {
153 code = await authorize("tesla");
154 const res = await exchange(code);
155 expect(res.status).toBe(200);
156 const out = (await res.json()) as any;
157 expect(out.token_type).toBe("Bearer");
158 token = out.access_token;
159
160 const init = await rpc("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "t", version: "1" } }, token);
161 expect(init.status).toBe(200);
162 expect(((await init.json()) as any).result.serverInfo).toBeDefined();
163 const list = (await (await rpc("tools/list", {}, token, 2)).json()) as any;
164 const names = list.result.tools.map((t: any) => t.name);
165 expect(names).toContain("tell");
166 expect(names).toContain("find_tool");
167
168 const told = await call("tell", { repo: "alice/notes", text: "Add a dark mode\nPlease." });
169 const number = (JSON.parse(told) as { issue: number }).issue;
170 expect(number).toBeGreaterThan(0);
171 const found = await call("issues", { repo: "alice/notes" });
172 expect(found).toContain("Add a dark mode");
173 });
174
175 test("a code used twice is refused", async () => {
176 const again = await exchange(code);
177 expect(again.status).toBe(400);
178 expect(((await again.json()) as any).error).toBe("invalid_grant");
179 });
180
181 test("a revoked token is refused with the one next thing to do", async () => {
182 const gone = await fetch(`${base}/oauth/revoke`, { method: "POST", body: new URLSearchParams({ token }) });
183 expect(gone.status).toBe(200);
184 const res = await rpc("tools/list", {}, token);
185 expect(res.status).toBe(401);
186 const challenge = res.headers.get("www-authenticate") ?? "";
187 expect(challenge).toContain('error="invalid_token"');
188 expect(challenge).toContain(`${base}/.well-known/oauth-protected-resource`);
189 });
190 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs