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.tsBlame290 lines · 1 contributor
9989d86Claude1/**
2 * MCP (Model Context Protocol) HTTP endpoint — 2024-11-05 spec.
3 *
4 * Single endpoint: POST /mcp
5 * Discovery: GET /mcp
6 *
7 * Authentication: Bearer token in the Authorization header.
8 * - Tokens prefixed `glc_` → PAT (api_tokens table)
9 * - Tokens prefixed `glct_` → OAuth access token (oauth_access_tokens table)
10 * - No token → unauthenticated (only certain read-only methods are allowed)
11 *
12 * JSON-RPC 2.0 methods:
13 * initialize
14 * notifications/initialized
15 * tools/list
16 * tools/call
17 * resources/list
18 */
19
20import { Hono } from "hono";
21import { eq } from "drizzle-orm";
22import { db } from "../db";
23import { apiTokens, users, oauthAccessTokens } from "../db/schema";
24import type { User } from "../db/schema";
25import { sha256Hex } from "../lib/oauth";
26import {
27 MCP_TOOLS,
28 MCP_TOOL_MAP,
29 McpToolError,
30 serializeTool,
31} from "../lib/mcp-tools";
32
33const mcp = new Hono();
34
35// --------------------------------------------------------------------------
36// Constants
37// --------------------------------------------------------------------------
38
39const PROTOCOL_VERSION = "2024-11-05";
40const SERVER_INFO = { name: "gluecron", version: "1.0.0" };
41
42// JSON-RPC error codes
43const E_PARSE = -32700;
44const E_INVALID_REQUEST = -32600;
45const E_METHOD_NOT_FOUND = -32601;
46const E_INVALID_PARAMS = -32602;
47const E_INTERNAL = -32603;
48const E_UNAUTHORIZED = -32001;
49const E_NOT_FOUND = -32004;
50
51// Methods that do NOT require authentication
52const PUBLIC_METHODS = new Set([
53 "initialize",
54 "notifications/initialized",
55 "tools/list",
56 "resources/list",
57]);
58
59// --------------------------------------------------------------------------
60// Auth helpers (inline — no middleware, raw handler)
61// --------------------------------------------------------------------------
62
63async function loadUserFromPat(token: string): Promise<User | null> {
64 if (!token.startsWith("glc_")) return null;
65 try {
66 const hash = await sha256Hex(token);
67 const [row] = await db
68 .select()
69 .from(apiTokens)
70 .where(eq(apiTokens.tokenHash, hash))
71 .limit(1);
72 if (!row) return null;
73 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
74 const [user] = await db
75 .select()
76 .from(users)
77 .where(eq(users.id, row.userId))
78 .limit(1);
79 if (!user) return null;
80 // Best-effort: update lastUsedAt
81 db.update(apiTokens)
82 .set({ lastUsedAt: new Date() })
83 .where(eq(apiTokens.id, row.id))
84 .catch(() => {});
85 return user;
86 } catch {
87 return null;
88 }
89}
90
91async function loadUserFromOauthBearer(token: string): Promise<User | null> {
92 if (!token.startsWith("glct_")) return null;
93 try {
94 const hash = await sha256Hex(token);
95 const [row] = await db
96 .select()
97 .from(oauthAccessTokens)
98 .where(eq(oauthAccessTokens.accessTokenHash, hash))
99 .limit(1);
100 if (!row) return null;
101 if (row.revokedAt) return null;
102 if (new Date(row.expiresAt) < new Date()) return null;
103 const [user] = await db
104 .select()
105 .from(users)
106 .where(eq(users.id, row.userId))
107 .limit(1);
108 if (!user) return null;
109 db.update(oauthAccessTokens)
110 .set({ lastUsedAt: new Date() })
111 .where(eq(oauthAccessTokens.id, row.id))
112 .catch(() => {});
113 return user;
114 } catch {
115 return null;
116 }
117}
118
119/**
120 * Extract the authenticated user (or null) from the Authorization header.
121 * Accepts both PAT (`glc_`) and OAuth bearer (`glct_`) tokens.
122 */
123async function resolveUser(authHeader: string | undefined): Promise<User | null> {
124 if (!authHeader) return null;
125 const lower = authHeader.toLowerCase();
126 if (!lower.startsWith("bearer ")) return null;
127 const token = authHeader.slice(7).trim();
128 if (!token) return null;
129
130 if (token.startsWith("glct_")) return loadUserFromOauthBearer(token);
131 if (token.startsWith("glc_")) return loadUserFromPat(token);
132 return null;
133}
134
135// --------------------------------------------------------------------------
136// JSON-RPC helpers
137// --------------------------------------------------------------------------
138
139type JsonRpcId = string | number | null;
140
141function rpcOk(id: JsonRpcId, result: unknown) {
142 return { jsonrpc: "2.0", id, result };
143}
144
145function rpcError(id: JsonRpcId, code: number, message: string, data?: unknown) {
146 const error: Record<string, unknown> = { code, message };
147 if (data !== undefined) error.data = data;
148 return { jsonrpc: "2.0", id, error };
149}
150
151// --------------------------------------------------------------------------
152// Method handlers
153// --------------------------------------------------------------------------
154
155function handleInitialize() {
156 return {
157 protocolVersion: PROTOCOL_VERSION,
158 capabilities: {
159 tools: {},
160 resources: { subscribe: false },
161 },
162 serverInfo: SERVER_INFO,
163 };
164}
165
166function handleToolsList() {
167 return { tools: MCP_TOOLS.map(serializeTool) };
168}
169
170function handleResourcesList() {
171 return { resources: [] };
172}
173
174async function handleToolsCall(
175 params: Record<string, unknown>,
176 user: User | null
177): Promise<unknown> {
178 const toolName = params.name as string | undefined;
179 if (!toolName) {
180 throw new McpToolError(E_INVALID_PARAMS, "params.name is required for tools/call");
181 }
182
183 const tool = MCP_TOOL_MAP.get(toolName);
184 if (!tool) {
185 throw new McpToolError(E_NOT_FOUND, `Unknown tool: '${toolName}'`);
186 }
187
188 const args = (params.arguments ?? {}) as Record<string, unknown>;
189 const result = await tool.handler(args, user);
190 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
191}
192
193// --------------------------------------------------------------------------
194// POST /mcp — main JSON-RPC dispatcher
195// --------------------------------------------------------------------------
196
197mcp.post("/mcp", async (c) => {
198 // Resolve authenticated user (may be null for public requests)
199 const authHeader = c.req.header("authorization");
200 const user = await resolveUser(authHeader);
201
202 // Parse body
203 let body: Record<string, unknown>;
204 try {
205 body = await c.req.json();
206 } catch {
207 return c.json(rpcError(null, E_PARSE, "Parse error: invalid JSON"), 200);
208 }
209
210 // Validate JSON-RPC envelope
211 if (body.jsonrpc !== "2.0" || !body.method) {
212 return c.json(
213 rpcError(body.id as JsonRpcId ?? null, E_INVALID_REQUEST, "Invalid JSON-RPC request"),
214 200
215 );
216 }
217
218 const id = (body.id as JsonRpcId) ?? null;
219 const method = body.method as string;
220 const params = (body.params ?? {}) as Record<string, unknown>;
221
222 // Auth gate: tools/call that mutate or read private data require auth
223 // (public methods are allowed without a token)
224 if (!PUBLIC_METHODS.has(method) && method !== "tools/call") {
225 // Any unknown method — we'll return method-not-found below, not an auth error
226 }
227
228 // For tools/call: auth is enforced at the tool level (each tool decides)
229 // but we still need a user object passed through.
230
231 try {
232 let result: unknown;
233
234 switch (method) {
235 case "initialize":
236 result = handleInitialize();
237 break;
238
239 case "notifications/initialized":
240 // Client acknowledgement — no-op, return empty result
241 result = {};
242 break;
243
244 case "tools/list":
245 result = handleToolsList();
246 break;
247
248 case "tools/call":
249 result = await handleToolsCall(params, user);
250 break;
251
252 case "resources/list":
253 result = handleResourcesList();
254 break;
255
256 default:
257 return c.json(rpcError(id, E_METHOD_NOT_FOUND, `Method not found: '${method}'`), 200);
258 }
259
260 return c.json(rpcOk(id, result), 200);
261 } catch (err) {
262 if (err instanceof McpToolError) {
263 return c.json(rpcError(id, err.code, err.message), 200);
264 }
265 // Unexpected error
266 console.error("[mcp] unhandled error:", err);
267 return c.json(rpcError(id, E_INTERNAL, "Internal error"), 200);
268 }
269});
270
271// --------------------------------------------------------------------------
272// GET /mcp — server discovery (no auth required)
273// --------------------------------------------------------------------------
274
275mcp.get("/mcp", async (c) => {
276 return c.json({
277 name: "gluecron",
278 version: "1.0.0",
279 description: "AI-native code intelligence platform",
280 transport: "http",
281 endpoint: "/mcp",
282 protocolVersion: PROTOCOL_VERSION,
283 tools: MCP_TOOLS.map((t) => ({
284 name: t.name,
285 description: t.description,
286 })),
287 });
288});
289
290export default mcp;