Commit0a6ebcd
feat(mcp): OAuth challenge on anonymous tool calls — one-click connector flow
feat(mcp): OAuth challenge on anonymous tool calls — one-click connector flow Completes the remote-connector story: when an anonymous client calls a tool, POST /mcp now returns 401 with `WWW-Authenticate: Bearer resource_metadata= "<host>/.well-known/oauth-protected-resource"`. That's the signal claude.ai / Cursor / Copilot follow to run the OAuth flow — so adding <host>/mcp as a connector and invoking a tool prompts sign-in, then works end to end (pairs with the DCR + discovery metadata already shipped). Kept deliberately narrow: the handshake (initialize/tools-list/ping/ notifications) stays open so any client can still introspect the server unauthenticated; only tools/call requires an identity. PAT / OAuth / session all satisfy it, so .mcp.json, the CLI, and the VS Code extension are unaffected. 3 new tests. Suite 2955 pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed+105−00a6ebcd2bf5a5c841448f6b658262a0be7183138
2 changed files+105−0
Modifiedsrc/__tests__/mcp.test.ts+54−0View fileUnifiedSplit
@@ -210,3 +210,57 @@ describe("HTTP route — POST /mcp JSON-RPC", () => {
210210 expect(body[1].result.tools.length).toBeGreaterThanOrEqual(4);
211211 });
212212});
213
214describe("HTTP route — POST /mcp OAuth challenge", () => {
215 // Anonymous tool calls must return the RFC 9728 challenge so remote
216 // connectors (claude.ai, Cursor, …) know to start the OAuth flow. The
217 // handshake stays open (covered by the tests above), so this is the one
218 // gate that triggers sign-in.
219 it("challenges an anonymous tools/call with 401 + WWW-Authenticate", async () => {
220 const res = await app.request("/mcp", {
221 method: "POST",
222 headers: { "content-type": "application/json" },
223 body: JSON.stringify({
224 jsonrpc: "2.0",
225 id: 7,
226 method: "tools/call",
227 params: { name: "gluecron_repo_search", arguments: { q: "x" } },
228 }),
229 });
230 expect(res.status).toBe(401);
231 const wwwAuth = res.headers.get("www-authenticate") || "";
232 expect(wwwAuth).toContain("Bearer");
233 expect(wwwAuth).toContain("resource_metadata=");
234 expect(wwwAuth).toContain("/.well-known/oauth-protected-resource");
235 const body = (await res.json()) as any;
236 expect(body.error.code).toBe(-32001);
237 expect(body.id).toBe(7); // echoes the request id
238 });
239
240 it("challenges when a batch contains a tools/call while anonymous", async () => {
241 const res = await app.request("/mcp", {
242 method: "POST",
243 headers: { "content-type": "application/json" },
244 body: JSON.stringify([
245 { jsonrpc: "2.0", id: 1, method: "initialize" },
246 {
247 jsonrpc: "2.0",
248 id: 2,
249 method: "tools/call",
250 params: { name: "gluecron_repo_search", arguments: { q: "x" } },
251 },
252 ]),
253 });
254 expect(res.status).toBe(401);
255 expect(res.headers.get("www-authenticate") || "").toContain("resource_metadata=");
256 });
257
258 it("still allows the anonymous handshake (no challenge on tools/list)", async () => {
259 const res = await app.request("/mcp", {
260 method: "POST",
261 headers: { "content-type": "application/json" },
262 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
263 });
264 expect(res.status).toBe(200);
265 });
266});
Modifiedsrc/routes/mcp.ts+51−0View fileUnifiedSplit
@@ -26,11 +26,51 @@ import {
2626 MCP_SERVER_VERSION,
2727} from "../lib/mcp";
2828import { defaultTools } from "../lib/mcp-tools";
29import { config } from "../lib/config";
2930
3031const mcp = new Hono<AuthEnv>();
3132
3233mcp.use("*", softAuth);
3334
35/**
36 * MCP authorization challenge (MCP auth spec + RFC 9728).
37 *
38 * When an anonymous client attempts to CALL a tool, respond 401 with a
39 * `WWW-Authenticate: Bearer resource_metadata="…"` header pointing at our
40 * protected-resource metadata. This is the signal a remote connector
41 * (claude.ai, Cursor, Copilot, …) follows to start the OAuth flow — so adding
42 * `<host>/mcp` as a connector and invoking a tool prompts sign-in, then works
43 * end to end.
44 *
45 * The handshake (initialize / tools/list / ping / notifications) stays open so
46 * any client can introspect the server unauthenticated — only `tools/call`
47 * requires an identity. Session cookie / PAT (`glc_`) / OAuth bearer (`glct_`)
48 * all satisfy it, so token clients (.mcp.json, CLI, VS Code extension) are
49 * unaffected. GET /mcp discovery also stays open.
50 */
51function authChallenge(c: Parameters<Parameters<typeof mcp.post>[1]>[0], id: unknown) {
52 const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
53 c.header("WWW-Authenticate", `Bearer resource_metadata="${rm}"`);
54 return c.json(
55 {
56 jsonrpc: "2.0",
57 id: id ?? null,
58 error: { code: -32001, message: "Authentication required" },
59 },
60 401
61 );
62}
63
64/** True when the JSON-RPC envelope (single or batch) invokes a tool. */
65function invokesTool(body: unknown): boolean {
66 const isCall = (e: unknown): boolean =>
67 !!e &&
68 typeof e === "object" &&
69 (e as { method?: unknown }).method === "tools/call";
70 if (Array.isArray(body)) return body.some(isCall);
71 return isCall(body);
72}
73
3474mcp.get("/mcp", (c) => {
3575 const tools = defaultTools();
3676 return c.json({
@@ -74,6 +114,17 @@ mcp.post("/mcp", async (c) => {
74114 );
75115 }
76116
117 // Anonymous tool calls trigger the OAuth discovery challenge so connectors
118 // sign in. The handshake (initialize/tools-list/ping/notifications) is left
119 // open above so clients can introspect without a token.
120 if (!user && invokesTool(body)) {
121 const id =
122 !Array.isArray(body) && body && typeof body === "object"
123 ? (body as { id?: unknown }).id ?? null
124 : null;
125 return authChallenge(c, id);
126 }
127
77128 if (Array.isArray(body)) {
78129 // Batched request — pass each through, drop nulls (notifications).
79130 const out = await Promise.all(
80131