Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsBlame198 lines · 3 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";
8ed88f2ccantynz-alt20import type { Context } from "hono";
2c2163eClaude21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import {
24 routeMcpRequest,
25 MCP_PROTOCOL_VERSION,
26 MCP_SERVER_NAME,
27 MCP_SERVER_VERSION,
28} from "../lib/mcp";
29import { defaultTools } from "../lib/mcp-tools";
0a6ebcdccanty labs30import { config } from "../lib/config";
2c2163eClaude31
32const mcp = new Hono<AuthEnv>();
33
34mcp.use("*", softAuth);
35
0a6ebcdccanty labs36/**
37 * MCP authorization challenge (MCP auth spec + RFC 9728).
38 *
39 * When an anonymous client attempts to CALL a tool, respond 401 with a
40 * `WWW-Authenticate: Bearer resource_metadata="…"` header pointing at our
41 * protected-resource metadata. This is the signal a remote connector
42 * (claude.ai, Cursor, Copilot, …) follows to start the OAuth flow — so adding
43 * `<host>/mcp` as a connector and invoking a tool prompts sign-in, then works
44 * end to end.
45 *
46 * The handshake (initialize / tools/list / ping / notifications) stays open so
47 * any client can introspect the server unauthenticated — only `tools/call`
48 * requires an identity. Session cookie / PAT (`glc_`) / OAuth bearer (`glct_`)
49 * all satisfy it, so token clients (.mcp.json, CLI, VS Code extension) are
50 * unaffected. GET /mcp discovery also stays open.
51 */
b5a7bb3ccanty labs52function authChallenge(
8ed88f2ccantynz-alt53 c: Context<AuthEnv>,
b5a7bb3ccanty labs54 id: unknown,
55 opts?: { invalidToken?: boolean }
56) {
0a6ebcdccanty labs57 const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
b5a7bb3ccanty labs58 if (opts?.invalidToken) {
59 // RFC 6750 §3.1: `error="invalid_token"` tells the client its token is
60 // expired/revoked, so it runs the refresh_token grant (or re-authorizes)
61 // instead of replaying the dead token forever. The anonymous challenge
62 // below deliberately omits `error=` — its absence means "no credentials
63 // at all, start first-time auth".
64 c.header(
65 "WWW-Authenticate",
66 `Bearer error="invalid_token", error_description="The access token is expired or invalid", resource_metadata="${rm}"`
67 );
68 return c.json(
69 {
70 jsonrpc: "2.0",
71 id: id ?? null,
72 error: {
73 code: -32001,
74 message: "Invalid or expired access token — refresh or re-authorize",
75 },
76 },
77 401
78 );
79 }
0a6ebcdccanty labs80 c.header("WWW-Authenticate", `Bearer resource_metadata="${rm}"`);
81 return c.json(
82 {
83 jsonrpc: "2.0",
84 id: id ?? null,
85 error: { code: -32001, message: "Authentication required" },
86 },
87 401
88 );
89}
90
b5a7bb3ccanty labs91/** Extract the JSON-RPC id from a single (non-batch) envelope, else null. */
92function idOf(body: unknown): unknown {
93 return !Array.isArray(body) && body && typeof body === "object"
94 ? (body as { id?: unknown }).id ?? null
95 : null;
96}
97
0a6ebcdccanty labs98/** True when the JSON-RPC envelope (single or batch) invokes a tool. */
99function invokesTool(body: unknown): boolean {
100 const isCall = (e: unknown): boolean =>
101 !!e &&
102 typeof e === "object" &&
103 (e as { method?: unknown }).method === "tools/call";
104 if (Array.isArray(body)) return body.some(isCall);
105 return isCall(body);
106}
107
2c2163eClaude108mcp.get("/mcp", (c) => {
b5a7bb3ccanty labs109 // A dead bearer on discovery gets the invalid_token signal too (plain JSON
110 // — this endpoint is not JSON-RPC). Anonymous discovery stays open.
111 if (c.get("bearerInvalid")) {
112 const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
113 c.header(
114 "WWW-Authenticate",
115 `Bearer error="invalid_token", error_description="The access token is expired or invalid", resource_metadata="${rm}"`
116 );
117 return c.json(
118 { error: "invalid_token", message: "Access token expired or invalid — refresh or re-authorize" },
119 401
120 );
121 }
2c2163eClaude122 const tools = defaultTools();
123 return c.json({
124 protocolVersion: MCP_PROTOCOL_VERSION,
125 serverInfo: { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION },
126 transport: "http",
127 toolCount: Object.keys(tools).length,
128 docs:
129 "POST /mcp with a JSON-RPC 2.0 envelope to call. See https://spec.modelcontextprotocol.io/",
130 });
131});
132
133mcp.post("/mcp", async (c) => {
134 const user = c.get("user") ?? null;
0feb720Claude135 // Scope derivation mirrors the API-auth middleware:
136 // - OAuth bearer (glct_) → oauthScopes from middleware
137 // - PAT (glc_) → oauthScopes from middleware
138 // - Session cookie → full ["repo","user","admin"]
139 // - Anonymous → []
140 const oauthScopes = c.get("oauthScopes");
141 let scopes: string[] = [];
142 if (Array.isArray(oauthScopes)) {
143 scopes = oauthScopes;
144 } else if (user) {
145 scopes = ["repo", "user", "admin"];
146 }
147 const ctx = { userId: user?.id ?? null, scopes };
2c2163eClaude148 const tools = defaultTools();
149
150 let body: unknown;
151 try {
152 body = await c.req.json();
153 } catch {
154 return c.json(
155 {
156 jsonrpc: "2.0",
157 id: null,
158 error: { code: -32700, message: "Parse error" },
159 },
160 400
161 );
162 }
163
b5a7bb3ccanty labs164 // A bearer token was presented but rejected (expired/revoked/unknown).
165 // Challenge EVERY method — including initialize — with invalid_token, so a
166 // client reconnecting on a dead token fails loudly and refreshes or
167 // re-authorizes, instead of getting a false-healthy handshake and then
168 // looping on tools/call. Truly-anonymous handshake stays open below.
169 if (c.get("bearerInvalid")) {
170 return authChallenge(c, idOf(body), { invalidToken: true });
171 }
172
0a6ebcdccanty labs173 // Anonymous tool calls trigger the OAuth discovery challenge so connectors
174 // sign in. The handshake (initialize/tools-list/ping/notifications) is left
175 // open above so clients can introspect without a token.
176 if (!user && invokesTool(body)) {
b5a7bb3ccanty labs177 return authChallenge(c, idOf(body));
0a6ebcdccanty labs178 }
179
2c2163eClaude180 if (Array.isArray(body)) {
181 // Batched request — pass each through, drop nulls (notifications).
182 const out = await Promise.all(
183 body.map((entry) => routeMcpRequest(entry, { ctx, tools }))
184 );
185 const filtered = out.filter((r): r is NonNullable<typeof r> => r !== null);
186 if (filtered.length === 0) return c.body(null, 204);
187 return c.json(filtered);
188 }
189
190 const result = await routeMcpRequest(body, { ctx, tools });
191 if (result === null) {
192 // Notification — no response body, 204.
193 return c.body(null, 204);
194 }
195 return c.json(result);
196});
197
198export default mcp;