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.tsBlame206 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";
3a15219ccantynz-alt29import { defaultTools, mcpListResources, mcpReadResource } from "../lib/mcp-tools";
30
31// Resource provider: makes repos browsable in clients (Claude Desktop's
32// resource/attachment picker). ctx flows through so private repos only show
33// for their owner.
34const mcpResources = {
35 list: (ctx: any) => mcpListResources(ctx),
36 read: (uri: string, ctx: any) => mcpReadResource(uri, ctx),
37};
0a6ebcdccanty labs38import { config } from "../lib/config";
2c2163eClaude39
40const mcp = new Hono<AuthEnv>();
41
42mcp.use("*", softAuth);
43
0a6ebcdccanty labs44/**
45 * MCP authorization challenge (MCP auth spec + RFC 9728).
46 *
47 * When an anonymous client attempts to CALL a tool, respond 401 with a
48 * `WWW-Authenticate: Bearer resource_metadata="…"` header pointing at our
49 * protected-resource metadata. This is the signal a remote connector
50 * (claude.ai, Cursor, Copilot, …) follows to start the OAuth flow — so adding
51 * `<host>/mcp` as a connector and invoking a tool prompts sign-in, then works
52 * end to end.
53 *
54 * The handshake (initialize / tools/list / ping / notifications) stays open so
55 * any client can introspect the server unauthenticated — only `tools/call`
56 * requires an identity. Session cookie / PAT (`glc_`) / OAuth bearer (`glct_`)
57 * all satisfy it, so token clients (.mcp.json, CLI, VS Code extension) are
58 * unaffected. GET /mcp discovery also stays open.
59 */
b5a7bb3ccanty labs60function authChallenge(
8ed88f2ccantynz-alt61 c: Context<AuthEnv>,
b5a7bb3ccanty labs62 id: unknown,
63 opts?: { invalidToken?: boolean }
64) {
0a6ebcdccanty labs65 const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
b5a7bb3ccanty labs66 if (opts?.invalidToken) {
67 // RFC 6750 §3.1: `error="invalid_token"` tells the client its token is
68 // expired/revoked, so it runs the refresh_token grant (or re-authorizes)
69 // instead of replaying the dead token forever. The anonymous challenge
70 // below deliberately omits `error=` — its absence means "no credentials
71 // at all, start first-time auth".
72 c.header(
73 "WWW-Authenticate",
74 `Bearer error="invalid_token", error_description="The access token is expired or invalid", resource_metadata="${rm}"`
75 );
76 return c.json(
77 {
78 jsonrpc: "2.0",
79 id: id ?? null,
80 error: {
81 code: -32001,
82 message: "Invalid or expired access token — refresh or re-authorize",
83 },
84 },
85 401
86 );
87 }
0a6ebcdccanty labs88 c.header("WWW-Authenticate", `Bearer resource_metadata="${rm}"`);
89 return c.json(
90 {
91 jsonrpc: "2.0",
92 id: id ?? null,
93 error: { code: -32001, message: "Authentication required" },
94 },
95 401
96 );
97}
98
b5a7bb3ccanty labs99/** Extract the JSON-RPC id from a single (non-batch) envelope, else null. */
100function idOf(body: unknown): unknown {
101 return !Array.isArray(body) && body && typeof body === "object"
102 ? (body as { id?: unknown }).id ?? null
103 : null;
104}
105
0a6ebcdccanty labs106/** True when the JSON-RPC envelope (single or batch) invokes a tool. */
107function invokesTool(body: unknown): boolean {
108 const isCall = (e: unknown): boolean =>
109 !!e &&
110 typeof e === "object" &&
111 (e as { method?: unknown }).method === "tools/call";
112 if (Array.isArray(body)) return body.some(isCall);
113 return isCall(body);
114}
115
2c2163eClaude116mcp.get("/mcp", (c) => {
b5a7bb3ccanty labs117 // A dead bearer on discovery gets the invalid_token signal too (plain JSON
118 // — this endpoint is not JSON-RPC). Anonymous discovery stays open.
119 if (c.get("bearerInvalid")) {
120 const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
121 c.header(
122 "WWW-Authenticate",
123 `Bearer error="invalid_token", error_description="The access token is expired or invalid", resource_metadata="${rm}"`
124 );
125 return c.json(
126 { error: "invalid_token", message: "Access token expired or invalid — refresh or re-authorize" },
127 401
128 );
129 }
2c2163eClaude130 const tools = defaultTools();
131 return c.json({
132 protocolVersion: MCP_PROTOCOL_VERSION,
133 serverInfo: { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION },
134 transport: "http",
135 toolCount: Object.keys(tools).length,
136 docs:
137 "POST /mcp with a JSON-RPC 2.0 envelope to call. See https://spec.modelcontextprotocol.io/",
138 });
139});
140
141mcp.post("/mcp", async (c) => {
142 const user = c.get("user") ?? null;
0feb720Claude143 // Scope derivation mirrors the API-auth middleware:
144 // - OAuth bearer (glct_) → oauthScopes from middleware
145 // - PAT (glc_) → oauthScopes from middleware
146 // - Session cookie → full ["repo","user","admin"]
147 // - Anonymous → []
148 const oauthScopes = c.get("oauthScopes");
149 let scopes: string[] = [];
150 if (Array.isArray(oauthScopes)) {
151 scopes = oauthScopes;
152 } else if (user) {
153 scopes = ["repo", "user", "admin"];
154 }
155 const ctx = { userId: user?.id ?? null, scopes };
2c2163eClaude156 const tools = defaultTools();
157
158 let body: unknown;
159 try {
160 body = await c.req.json();
161 } catch {
162 return c.json(
163 {
164 jsonrpc: "2.0",
165 id: null,
166 error: { code: -32700, message: "Parse error" },
167 },
168 400
169 );
170 }
171
b5a7bb3ccanty labs172 // A bearer token was presented but rejected (expired/revoked/unknown).
173 // Challenge EVERY method — including initialize — with invalid_token, so a
174 // client reconnecting on a dead token fails loudly and refreshes or
175 // re-authorizes, instead of getting a false-healthy handshake and then
176 // looping on tools/call. Truly-anonymous handshake stays open below.
177 if (c.get("bearerInvalid")) {
178 return authChallenge(c, idOf(body), { invalidToken: true });
179 }
180
0a6ebcdccanty labs181 // Anonymous tool calls trigger the OAuth discovery challenge so connectors
182 // sign in. The handshake (initialize/tools-list/ping/notifications) is left
183 // open above so clients can introspect without a token.
184 if (!user && invokesTool(body)) {
b5a7bb3ccanty labs185 return authChallenge(c, idOf(body));
0a6ebcdccanty labs186 }
187
2c2163eClaude188 if (Array.isArray(body)) {
189 // Batched request — pass each through, drop nulls (notifications).
190 const out = await Promise.all(
3a15219ccantynz-alt191 body.map((entry) => routeMcpRequest(entry, { ctx, tools, resources: mcpResources }))
2c2163eClaude192 );
193 const filtered = out.filter((r): r is NonNullable<typeof r> => r !== null);
194 if (filtered.length === 0) return c.body(null, 204);
195 return c.json(filtered);
196 }
197
3a15219ccantynz-alt198 const result = await routeMcpRequest(body, { ctx, tools, resources: mcpResources });
2c2163eClaude199 if (result === null) {
200 // Notification — no response body, 204.
201 return c.body(null, 204);
202 }
203 return c.json(result);
204});
205
206export default mcp;