Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

mcp.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

mcp.tsBlame197 lines · 2 contributors
2c2163eClaude1/**
2 * Model Context Protocol HTTP transport.
3 *
4 * POST /mcp — JSON-RPC 2.0 requests; body is a single request
5 * or an array (batch). Response shape mirrors.
6 * GET /mcp — Lightweight discovery: returns server info +
7 * protocol version + tool count.
8 *
9 * Auth: softAuth — `userId` in the McpContext is the cookie/PAT/OAuth
10 * user when present, null otherwise. v1 tools are read-only and public-
11 * only, so anonymous works; write tools (v2) will require requireAuth +
12 * write-access on the target repo.
13 *
14 * Streamable-HTTP-mode is the recommended MCP transport for stateless
15 * cloud servers. We don't emit server-sent notifications yet, so the
16 * route is plain JSON in / JSON out.
17 */
18
19import { Hono } from "hono";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import {
23 routeMcpRequest,
24 MCP_PROTOCOL_VERSION,
25 MCP_SERVER_NAME,
26 MCP_SERVER_VERSION,
27} from "../lib/mcp";
28import { defaultTools } from "../lib/mcp-tools";
0a6ebcdccanty labs29import { config } from "../lib/config";
2c2163eClaude30
31const mcp = new Hono<AuthEnv>();
32
33mcp.use("*", softAuth);
34
0a6ebcdccanty labs35/**
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 */
b5a7bb3ccanty labs51function authChallenge(
52 c: Parameters<Parameters<typeof mcp.post>[1]>[0],
53 id: unknown,
54 opts?: { invalidToken?: boolean }
55) {
0a6ebcdccanty labs56 const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
b5a7bb3ccanty labs57 if (opts?.invalidToken) {
58 // RFC 6750 §3.1: `error="invalid_token"` tells the client its token is
59 // expired/revoked, so it runs the refresh_token grant (or re-authorizes)
60 // instead of replaying the dead token forever. The anonymous challenge
61 // below deliberately omits `error=` — its absence means "no credentials
62 // at all, start first-time auth".
63 c.header(
64 "WWW-Authenticate",
65 `Bearer error="invalid_token", error_description="The access token is expired or invalid", resource_metadata="${rm}"`
66 );
67 return c.json(
68 {
69 jsonrpc: "2.0",
70 id: id ?? null,
71 error: {
72 code: -32001,
73 message: "Invalid or expired access token — refresh or re-authorize",
74 },
75 },
76 401
77 );
78 }
0a6ebcdccanty labs79 c.header("WWW-Authenticate", `Bearer resource_metadata="${rm}"`);
80 return c.json(
81 {
82 jsonrpc: "2.0",
83 id: id ?? null,
84 error: { code: -32001, message: "Authentication required" },
85 },
86 401
87 );
88}
89
b5a7bb3ccanty labs90/** Extract the JSON-RPC id from a single (non-batch) envelope, else null. */
91function idOf(body: unknown): unknown {
92 return !Array.isArray(body) && body && typeof body === "object"
93 ? (body as { id?: unknown }).id ?? null
94 : null;
95}
96
0a6ebcdccanty labs97/** True when the JSON-RPC envelope (single or batch) invokes a tool. */
98function invokesTool(body: unknown): boolean {
99 const isCall = (e: unknown): boolean =>
100 !!e &&
101 typeof e === "object" &&
102 (e as { method?: unknown }).method === "tools/call";
103 if (Array.isArray(body)) return body.some(isCall);
104 return isCall(body);
105}
106
2c2163eClaude107mcp.get("/mcp", (c) => {
b5a7bb3ccanty labs108 // A dead bearer on discovery gets the invalid_token signal too (plain JSON
109 // — this endpoint is not JSON-RPC). Anonymous discovery stays open.
110 if (c.get("bearerInvalid")) {
111 const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
112 c.header(
113 "WWW-Authenticate",
114 `Bearer error="invalid_token", error_description="The access token is expired or invalid", resource_metadata="${rm}"`
115 );
116 return c.json(
117 { error: "invalid_token", message: "Access token expired or invalid — refresh or re-authorize" },
118 401
119 );
120 }
2c2163eClaude121 const tools = defaultTools();
122 return c.json({
123 protocolVersion: MCP_PROTOCOL_VERSION,
124 serverInfo: { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION },
125 transport: "http",
126 toolCount: Object.keys(tools).length,
127 docs:
128 "POST /mcp with a JSON-RPC 2.0 envelope to call. See https://spec.modelcontextprotocol.io/",
129 });
130});
131
132mcp.post("/mcp", async (c) => {
133 const user = c.get("user") ?? null;
0feb720Claude134 // Scope derivation mirrors the API-auth middleware:
135 // - OAuth bearer (glct_) → oauthScopes from middleware
136 // - PAT (glc_) → oauthScopes from middleware
137 // - Session cookie → full ["repo","user","admin"]
138 // - Anonymous → []
139 const oauthScopes = c.get("oauthScopes");
140 let scopes: string[] = [];
141 if (Array.isArray(oauthScopes)) {
142 scopes = oauthScopes;
143 } else if (user) {
144 scopes = ["repo", "user", "admin"];
145 }
146 const ctx = { userId: user?.id ?? null, scopes };
2c2163eClaude147 const tools = defaultTools();
148
149 let body: unknown;
150 try {
151 body = await c.req.json();
152 } catch {
153 return c.json(
154 {
155 jsonrpc: "2.0",
156 id: null,
157 error: { code: -32700, message: "Parse error" },
158 },
159 400
160 );
161 }
162
b5a7bb3ccanty labs163 // A bearer token was presented but rejected (expired/revoked/unknown).
164 // Challenge EVERY method — including initialize — with invalid_token, so a
165 // client reconnecting on a dead token fails loudly and refreshes or
166 // re-authorizes, instead of getting a false-healthy handshake and then
167 // looping on tools/call. Truly-anonymous handshake stays open below.
168 if (c.get("bearerInvalid")) {
169 return authChallenge(c, idOf(body), { invalidToken: true });
170 }
171
0a6ebcdccanty labs172 // Anonymous tool calls trigger the OAuth discovery challenge so connectors
173 // sign in. The handshake (initialize/tools-list/ping/notifications) is left
174 // open above so clients can introspect without a token.
175 if (!user && invokesTool(body)) {
b5a7bb3ccanty labs176 return authChallenge(c, idOf(body));
0a6ebcdccanty labs177 }
178
2c2163eClaude179 if (Array.isArray(body)) {
180 // Batched request — pass each through, drop nulls (notifications).
181 const out = await Promise.all(
182 body.map((entry) => routeMcpRequest(entry, { ctx, tools }))
183 );
184 const filtered = out.filter((r): r is NonNullable<typeof r> => r !== null);
185 if (filtered.length === 0) return c.body(null, 204);
186 return c.json(filtered);
187 }
188
189 const result = await routeMcpRequest(body, { ctx, tools });
190 if (result === null) {
191 // Notification — no response body, 204.
192 return c.body(null, 204);
193 }
194 return c.json(result);
195});
196
197export default mcp;