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.test.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.test.tsBlame372 lines · 2 contributors
2c2163eClaude1/**
2 * Tests for src/lib/mcp.ts (router) + src/lib/mcp-tools.ts (tools).
3 *
4 * The DB-touching tool runs require live Postgres; we focus on:
5 * - JSON-RPC envelope shape (initialize / tools/list / unknown method)
6 * - Notification handling (no `id` → no response)
7 * - tools/call validation (missing name, unknown tool)
8 * - Tool manifest shape (every default tool has a name + inputSchema)
9 * - Pure helper edge cases (argString / argNumber)
10 * - The HTTP route's discovery GET + JSON-RPC POST shape
11 */
12
13import { describe, it, expect } from "bun:test";
14import {
15 routeMcpRequest,
16 MCP_PROTOCOL_VERSION,
17 MCP_SERVER_NAME,
18 ERR_METHOD_NOT_FOUND,
19 ERR_INVALID_REQUEST,
20 ERR_INVALID_PARAMS,
21 McpError,
22} from "../lib/mcp";
23import { defaultTools, __test } from "../lib/mcp-tools";
24import app from "../app";
25
26const tools = defaultTools();
27const ctx = { userId: null };
28
29describe("routeMcpRequest — initialize", () => {
30 it("returns the protocol version + server info", async () => {
31 const r = await routeMcpRequest(
32 { jsonrpc: "2.0", id: 1, method: "initialize" },
33 { ctx, tools }
34 );
35 expect(r).not.toBeNull();
36 if (!r || "error" in r) throw new Error("expected success");
37 const result = r.result as any;
38 expect(result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
39 expect(result.serverInfo.name).toBe(MCP_SERVER_NAME);
40 expect(result.capabilities.tools).toBeDefined();
41 });
42});
43
44describe("routeMcpRequest — invalid input", () => {
45 it("rejects a non-JSON-RPC envelope", async () => {
46 const r = await routeMcpRequest({ method: "x" } as any, { ctx, tools });
47 if (!r || "result" in r) throw new Error("expected error");
48 expect(r.error.code).toBe(ERR_INVALID_REQUEST);
49 });
50
51 it("rejects an unknown method", async () => {
52 const r = await routeMcpRequest(
53 { jsonrpc: "2.0", id: 1, method: "no_such_method" },
54 { ctx, tools }
55 );
56 if (!r || "result" in r) throw new Error("expected error");
57 expect(r.error.code).toBe(ERR_METHOD_NOT_FOUND);
58 });
59
60 it("returns null for a notification (no `id`)", async () => {
61 const r = await routeMcpRequest(
62 { jsonrpc: "2.0", method: "notifications/initialized" },
63 { ctx, tools }
64 );
65 expect(r).toBeNull();
66 });
67});
68
69describe("routeMcpRequest — tools/list", () => {
70 it("returns the full tool manifest", async () => {
71 const r = await routeMcpRequest(
72 { jsonrpc: "2.0", id: 2, method: "tools/list" },
73 { ctx, tools }
74 );
75 if (!r || "error" in r) throw new Error("expected success");
76 const result = r.result as { tools: Array<{ name: string; inputSchema: any }> };
77 expect(Array.isArray(result.tools)).toBe(true);
78 expect(result.tools.length).toBeGreaterThanOrEqual(4);
79 for (const t of result.tools) {
80 expect(typeof t.name).toBe("string");
81 expect(t.name).toMatch(/^gluecron_/);
82 expect(t.inputSchema.type).toBe("object");
83 }
84 });
85});
86
87describe("routeMcpRequest — tools/call validation", () => {
88 it("rejects calls without a name", async () => {
89 const r = await routeMcpRequest(
90 { jsonrpc: "2.0", id: 3, method: "tools/call", params: {} },
91 { ctx, tools }
92 );
93 if (!r || "result" in r) throw new Error("expected error");
94 expect(r.error.code).toBe(ERR_INVALID_PARAMS);
95 });
96
97 it("rejects calls with an unknown tool name", async () => {
98 const r = await routeMcpRequest(
99 {
100 jsonrpc: "2.0",
101 id: 4,
102 method: "tools/call",
103 params: { name: "no_such_tool", arguments: {} },
104 },
105 { ctx, tools }
106 );
107 if (!r || "result" in r) throw new Error("expected error");
108 expect(r.error.code).toBe(ERR_METHOD_NOT_FOUND);
109 });
110});
111
112describe("argString / argNumber pure helpers", () => {
113 it("returns the trimmed string when present", () => {
114 expect(__test.argString({ x: " hi " }, "x")).toBe("hi");
115 });
116
117 it("falls back when missing", () => {
118 expect(__test.argString({}, "x", "default")).toBe("default");
119 });
120
121 it("throws McpError when missing without fallback", () => {
122 expect(() => __test.argString({}, "x")).toThrow(McpError);
123 });
124
125 it("argNumber accepts numeric strings", () => {
126 expect(__test.argNumber({ n: "42" }, "n")).toBe(42);
127 });
128
129 it("argNumber falls back on non-numeric input", () => {
130 expect(__test.argNumber({ n: "abc" }, "n", 7)).toBe(7);
131 });
132});
133
134describe("tool manifest shape", () => {
135 for (const handler of Object.values(tools)) {
136 it(`${handler.tool.name} — required fields populated`, () => {
137 expect(handler.tool.name).toMatch(/^gluecron_/);
138 expect(typeof handler.tool.description).toBe("string");
139 expect(handler.tool.description.length).toBeGreaterThan(10);
140 expect(handler.tool.inputSchema.type).toBe("object");
141 expect(typeof handler.tool.inputSchema.properties).toBe("object");
142 });
143 }
144});
145
523ddadccanty labs146describe("tool annotations (Anthropic connector-directory requirement)", () => {
147 // Every tool must carry MCP ToolAnnotations with an explicit boolean
148 // readOnlyHint (and destructiveHint) so directory review can classify it.
149 for (const handler of Object.values(tools)) {
150 it(`${handler.tool.name} — has annotations with boolean readOnlyHint`, () => {
151 expect(handler.tool.annotations).toBeDefined();
152 expect(typeof handler.tool.annotations?.readOnlyHint).toBe("boolean");
153 expect(typeof handler.tool.annotations?.destructiveHint).toBe("boolean");
154 });
155 }
156
157 it("gluecron_repo_search is annotated read-only", () => {
158 const t = tools["gluecron_repo_search"].tool;
159 expect(t.annotations?.readOnlyHint).toBe(true);
160 });
161
162 it("gluecron_delete_repo is annotated destructive", () => {
163 const t = tools["gluecron_delete_repo"].tool;
164 expect(t.annotations?.readOnlyHint).toBe(false);
165 expect(t.annotations?.destructiveHint).toBe(true);
166 });
167
168 it("tools/list passes annotations through to the wire", async () => {
169 const r = await routeMcpRequest(
170 { jsonrpc: "2.0", id: 8, method: "tools/list" },
171 { ctx, tools }
172 );
173 if (!r || "error" in r) throw new Error("expected success");
174 const result = r.result as { tools: Array<{ name: string; annotations?: any }> };
175 const search = result.tools.find((t) => t.name === "gluecron_repo_search");
176 expect(search?.annotations?.readOnlyHint).toBe(true);
177 });
178});
179
2c2163eClaude180describe("HTTP route — GET /mcp discovery", () => {
181 it("returns server info + tool count", async () => {
182 const res = await app.request("/mcp");
183 expect(res.status).toBe(200);
184 const body = (await res.json()) as any;
185 expect(body.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
186 expect(body.serverInfo.name).toBe(MCP_SERVER_NAME);
187 expect(body.toolCount).toBeGreaterThanOrEqual(4);
188 });
189});
190
191describe("HTTP route — POST /mcp JSON-RPC", () => {
192 it("answers initialize over the wire", async () => {
193 const res = await app.request("/mcp", {
194 method: "POST",
195 headers: { "content-type": "application/json" },
196 body: JSON.stringify({
197 jsonrpc: "2.0",
198 id: 1,
199 method: "initialize",
200 }),
201 });
202 expect(res.status).toBe(200);
203 const body = (await res.json()) as any;
204 expect(body.jsonrpc).toBe("2.0");
205 expect(body.id).toBe(1);
206 expect(body.result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
207 });
208
209 it("returns 400 on invalid JSON", async () => {
210 const res = await app.request("/mcp", {
211 method: "POST",
212 headers: { "content-type": "application/json" },
213 body: "not json",
214 });
215 expect(res.status).toBe(400);
216 });
217
218 it("returns 204 for a single notification (no `id`)", async () => {
219 const res = await app.request("/mcp", {
220 method: "POST",
221 headers: { "content-type": "application/json" },
222 body: JSON.stringify({
223 jsonrpc: "2.0",
224 method: "notifications/initialized",
225 }),
226 });
227 expect(res.status).toBe(204);
228 });
229
230 it("supports a batched request", async () => {
231 const res = await app.request("/mcp", {
232 method: "POST",
233 headers: { "content-type": "application/json" },
234 body: JSON.stringify([
235 { jsonrpc: "2.0", id: 1, method: "initialize" },
236 { jsonrpc: "2.0", id: 2, method: "tools/list" },
237 ]),
238 });
239 expect(res.status).toBe(200);
240 const body = (await res.json()) as any[];
241 expect(body.length).toBe(2);
242 expect(body[0].id).toBe(1);
243 expect(body[1].id).toBe(2);
244 expect(body[1].result.tools.length).toBeGreaterThanOrEqual(4);
245 });
246});
0a6ebcdccanty labs247
248describe("HTTP route — POST /mcp OAuth challenge", () => {
249 // Anonymous tool calls must return the RFC 9728 challenge so remote
250 // connectors (claude.ai, Cursor, …) know to start the OAuth flow. The
251 // handshake stays open (covered by the tests above), so this is the one
252 // gate that triggers sign-in.
253 it("challenges an anonymous tools/call with 401 + WWW-Authenticate", async () => {
254 const res = await app.request("/mcp", {
255 method: "POST",
256 headers: { "content-type": "application/json" },
257 body: JSON.stringify({
258 jsonrpc: "2.0",
259 id: 7,
260 method: "tools/call",
261 params: { name: "gluecron_repo_search", arguments: { q: "x" } },
262 }),
263 });
264 expect(res.status).toBe(401);
265 const wwwAuth = res.headers.get("www-authenticate") || "";
266 expect(wwwAuth).toContain("Bearer");
267 expect(wwwAuth).toContain("resource_metadata=");
268 expect(wwwAuth).toContain("/.well-known/oauth-protected-resource");
b5a7bb3ccanty labs269 // RFC 6750: the ANONYMOUS challenge must NOT carry `error=` — its absence
270 // is what tells the client "no credentials, start first-time auth" rather
271 // than "your token died, refresh it".
272 expect(wwwAuth).not.toContain("error=");
0a6ebcdccanty labs273 const body = (await res.json()) as any;
274 expect(body.error.code).toBe(-32001);
275 expect(body.id).toBe(7); // echoes the request id
276 });
277
278 it("challenges when a batch contains a tools/call while anonymous", async () => {
279 const res = await app.request("/mcp", {
280 method: "POST",
281 headers: { "content-type": "application/json" },
282 body: JSON.stringify([
283 { jsonrpc: "2.0", id: 1, method: "initialize" },
284 {
285 jsonrpc: "2.0",
286 id: 2,
287 method: "tools/call",
288 params: { name: "gluecron_repo_search", arguments: { q: "x" } },
289 },
290 ]),
291 });
292 expect(res.status).toBe(401);
293 expect(res.headers.get("www-authenticate") || "").toContain("resource_metadata=");
294 });
295
296 it("still allows the anonymous handshake (no challenge on tools/list)", async () => {
297 const res = await app.request("/mcp", {
298 method: "POST",
299 headers: { "content-type": "application/json" },
300 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
301 });
302 expect(res.status).toBe(200);
303 });
304});
b5a7bb3ccanty labs305
306describe("HTTP route — POST /mcp invalid bearer challenge (RFC 6750)", () => {
307 // A presented-but-dead bearer (expired / revoked / unknown) must fail
308 // LOUDLY on every method — including the handshake — with
309 // `error="invalid_token"`, so clients run the refresh grant or re-auth
310 // instead of holding a false-healthy connection. Regression guard for the
311 // 2026-07-14 "token expired" reconnect loop.
312 const rpc = (method: string, bearer: string, id = 9) =>
313 app.request("/mcp", {
314 method: "POST",
315 headers: {
316 "content-type": "application/json",
317 authorization: `Bearer ${bearer}`,
318 },
319 body: JSON.stringify({ jsonrpc: "2.0", id, method, params: {} }),
320 });
321
322 const expectInvalidToken = async (res: Response) => {
323 expect(res.status).toBe(401);
324 const wwwAuth = res.headers.get("www-authenticate") || "";
325 expect(wwwAuth).toContain('error="invalid_token"');
326 expect(wwwAuth).toContain("resource_metadata=");
327 const body = (await res.json()) as any;
328 expect(body.error.code).toBe(-32001);
329 expect(String(body.error.message)).toMatch(/expired|invalid/i);
330 };
331
332 it("challenges an unknown glct_ token on tools/call", async () => {
333 await expectInvalidToken(await rpc("tools/call", "glct_deadbeef"));
334 });
335
336 it("challenges an unknown glct_ token on initialize (breaks the reconnect loop)", async () => {
337 await expectInvalidToken(await rpc("initialize", "glct_deadbeef"));
338 });
339
340 it("challenges an unknown glct_ token on tools/list", async () => {
341 await expectInvalidToken(await rpc("tools/list", "glct_deadbeef"));
342 });
343
344 it("challenges an unknown glc_ PAT on initialize", async () => {
345 await expectInvalidToken(await rpc("initialize", "glc_deadbeef"));
346 });
347
348 it("echoes the request id in the invalid_token error", async () => {
349 const res = await rpc("initialize", "glct_deadbeef", 42);
350 const body = (await res.json()) as any;
351 expect(body.id).toBe(42);
352 });
353
354 it("challenges GET /mcp discovery with a dead bearer, plain-JSON body", async () => {
355 const res = await app.request("/mcp", {
356 headers: { authorization: "Bearer glct_deadbeef" },
357 });
358 expect(res.status).toBe(401);
359 expect(res.headers.get("www-authenticate") || "").toContain(
360 'error="invalid_token"'
361 );
362 const body = (await res.json()) as any;
363 expect(body.error).toBe("invalid_token");
364 });
365
366 it("a non-gluecron bearer scheme is ignored (anonymous handshake works)", async () => {
367 // e.g. an unrelated `Bearer xyz` (no glct_/glc_ prefix) must not trip the
368 // invalid-token gate — softAuth only flags gluecron-shaped tokens.
369 const res = await rpc("tools/list", "some-other-token");
370 expect(res.status).toBe(200);
371 });
372});