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

One test walks an MCP client from nothing to a tool call, using only what the forge advertises #2486

Merged opened this

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

Merged

scout/forge:scout/2484-one-test-walks-an-mcp-client-from-nothin → main 1 commit fork

  • Merged. scout/2484-one-test-walks-an-mcp-client-from-nothin was merged into main as 827d5f3.

Review

Sign in to review this change.
1 file changed +181 −0
test/mcp-discovery-walk.test.ts added +181
@@ -0,0 +1,181 @@
1 /**
2 * A new MCP client's whole road, in order, using only what the forge
3 * advertises: an unauthenticated request, its 401 challenge, the
4 * protected-resource document, the authorization-server document, dynamic
5 * registration, authorize with S256 (as a signed-in, consenting user), the
6 * token, and a tools/list with it. Each next URL is read from the previous
7 * answer, never written down here. The same walk with `plain` stops at authorize.
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 { createSession } from "../src/core/sessions.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17
18 const REDIRECT = "https://client.example/callback";
19 const verifier = "w".repeat(60);
20 const challenge = new Bun.CryptoHasher("sha256").update(verifier).digest("base64url");
21
22 describe("an MCP client walks from nothing to a tool list", () => {
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 let base: string;
27 let cookie: string;
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-walk-"));
31 app = createApp({ dataDir, port: 0 });
32 servers = await serve(app);
33 base = servers.url;
34 const made = await createUser(app.db, {
35 trusted: true,
36 username: "walker",
37 email: "[email protected]",
38 password: "a-good-passphrase",
39 adminIfFirst: true,
40 });
41 if (!made.ok) throw new Error(made.error);
42 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
43 });
44 afterAll(async () => {
45 servers.stop();
46 await rm(dataDir, { recursive: true, force: true });
47 });
48
49 const listTools = (token?: string) =>
50 fetch(`${base}/mcp`, {
51 method: "POST",
52 headers: {
53 "content-type": "application/json",
54 accept: "application/json, text/event-stream",
55 ...(token ? { authorization: `Bearer ${token}` } : {}),
56 },
57 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
58 });
59 const json = async (url: string, init?: RequestInit) => {
60 const res = await fetch(url, init);
61 return { res, doc: (await res.json()) as any };
62 };
63
64 /** Everything up to authorize, each URL taken from the answer before. */
65 const discover = async () => {
66 const first = await listTools();
67 expect(first.status).toBe(401);
68 const metadataUrl = /resource_metadata="([^"]+)"/.exec(
69 first.headers.get("www-authenticate") ?? "",
70 )?.[1];
71 expect(metadataUrl).toBeString();
72
73 const resource = (await json(metadataUrl!)).doc;
74 const issuer: string = resource.authorization_servers[0];
75 expect(issuer).toBeString();
76
77 const server = (
78 await json(new URL("/.well-known/oauth-authorization-server", issuer).href)
79 ).doc;
80 expect(server.code_challenge_methods_supported).toContain("S256");
81
82 const reg = await json(server.registration_endpoint, {
83 method: "POST",
84 headers: { "content-type": "application/json" },
85 body: JSON.stringify({ client_name: "Walker", redirect_uris: [REDIRECT] }),
86 });
87 expect(reg.res.status).toBe(201);
88 return { resource, server, clientId: reg.doc.client_id as string };
89 };
90
91 const params = (clientId: string, method: string) =>
92 new URLSearchParams({
93 response_type: "code",
94 client_id: clientId,
95 redirect_uri: REDIRECT,
96 code_challenge: challenge,
97 code_challenge_method: method,
98 state: "walk-1",
99 });
100
101 test("401 to tools/list with a token, using only advertised URLs", async () => {
102 const { resource, server, clientId } = await discover();
103 const q = params(clientId, "S256");
104
105 const page = await fetch(`${server.authorization_endpoint}?${q}`, {
106 redirect: "manual",
107 headers: { cookie },
108 });
109 expect(page.status).toBe(200);
110
111 const allowed = await fetch(server.authorization_endpoint, {
112 method: "POST",
113 redirect: "manual",
114 headers: { cookie, origin: base },
115 body: new URLSearchParams({
116 ...Object.fromEntries(q),
117 decision: "allow",
118 grant: "ro",
119 name: "walk",
120 lifetime: "never",
121 }),
122 });
123 expect(allowed.status).toBe(303);
124 const back = new URL(allowed.headers.get("location")!);
125 expect(`${back.origin}${back.pathname}`).toBe(REDIRECT);
126 expect(back.searchParams.get("state")).toBe("walk-1");
127 const code = back.searchParams.get("code")!;
128 expect(code).toBeString();
129
130 const token = await json(server.token_endpoint, {
131 method: "POST",
132 body: new URLSearchParams({
133 grant_type: "authorization_code",
134 client_id: clientId,
135 redirect_uri: REDIRECT,
136 code,
137 code_verifier: verifier,
138 }),
139 });
140 expect(token.res.status).toBe(200);
141 expect(token.doc.access_token).toBeString();
142
143 // The token is used against the resource the metadata named.
144 const res = await fetch(resource.resource, {
145 method: "POST",
146 headers: {
147 "content-type": "application/json",
148 accept: "application/json, text/event-stream",
149 authorization: `Bearer ${token.doc.access_token}`,
150 },
151 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
152 });
153 expect(res.status).toBe(200);
154 const out = (await res.json()) as any;
155 expect(out.result.tools.length).toBeGreaterThan(0);
156 });
157
158 test("the same walk with plain is refused at authorize", async () => {
159 const { server, clientId } = await discover();
160 const q = params(clientId, "plain");
161 const page = await fetch(`${server.authorization_endpoint}?${q}`, {
162 redirect: "manual",
163 headers: { cookie },
164 });
165 expect(page.status).toBe(400);
166 const post = await fetch(server.authorization_endpoint, {
167 method: "POST",
168 redirect: "manual",
169 headers: { cookie, origin: base },
170 body: new URLSearchParams({
171 ...Object.fromEntries(q),
172 decision: "allow",
173 grant: "ro",
174 name: "walk",
175 lifetime: "never",
176 }),
177 });
178 expect(post.status).toBe(400);
179 expect(post.headers.get("location")).toBeNull();
180 });
181 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs