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.tsBlame338 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
146describe("HTTP route — GET /mcp discovery", () => {
147 it("returns server info + tool count", async () => {
148 const res = await app.request("/mcp");
149 expect(res.status).toBe(200);
150 const body = (await res.json()) as any;
151 expect(body.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
152 expect(body.serverInfo.name).toBe(MCP_SERVER_NAME);
153 expect(body.toolCount).toBeGreaterThanOrEqual(4);
154 });
155});
156
157describe("HTTP route — POST /mcp JSON-RPC", () => {
158 it("answers initialize over the wire", async () => {
159 const res = await app.request("/mcp", {
160 method: "POST",
161 headers: { "content-type": "application/json" },
162 body: JSON.stringify({
163 jsonrpc: "2.0",
164 id: 1,
165 method: "initialize",
166 }),
167 });
168 expect(res.status).toBe(200);
169 const body = (await res.json()) as any;
170 expect(body.jsonrpc).toBe("2.0");
171 expect(body.id).toBe(1);
172 expect(body.result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
173 });
174
175 it("returns 400 on invalid JSON", async () => {
176 const res = await app.request("/mcp", {
177 method: "POST",
178 headers: { "content-type": "application/json" },
179 body: "not json",
180 });
181 expect(res.status).toBe(400);
182 });
183
184 it("returns 204 for a single notification (no `id`)", async () => {
185 const res = await app.request("/mcp", {
186 method: "POST",
187 headers: { "content-type": "application/json" },
188 body: JSON.stringify({
189 jsonrpc: "2.0",
190 method: "notifications/initialized",
191 }),
192 });
193 expect(res.status).toBe(204);
194 });
195
196 it("supports a batched request", async () => {
197 const res = await app.request("/mcp", {
198 method: "POST",
199 headers: { "content-type": "application/json" },
200 body: JSON.stringify([
201 { jsonrpc: "2.0", id: 1, method: "initialize" },
202 { jsonrpc: "2.0", id: 2, method: "tools/list" },
203 ]),
204 });
205 expect(res.status).toBe(200);
206 const body = (await res.json()) as any[];
207 expect(body.length).toBe(2);
208 expect(body[0].id).toBe(1);
209 expect(body[1].id).toBe(2);
210 expect(body[1].result.tools.length).toBeGreaterThanOrEqual(4);
211 });
212});
0a6ebcdccanty labs213
214describe("HTTP route — POST /mcp OAuth challenge", () => {
215 // Anonymous tool calls must return the RFC 9728 challenge so remote
216 // connectors (claude.ai, Cursor, …) know to start the OAuth flow. The
217 // handshake stays open (covered by the tests above), so this is the one
218 // gate that triggers sign-in.
219 it("challenges an anonymous tools/call with 401 + WWW-Authenticate", async () => {
220 const res = await app.request("/mcp", {
221 method: "POST",
222 headers: { "content-type": "application/json" },
223 body: JSON.stringify({
224 jsonrpc: "2.0",
225 id: 7,
226 method: "tools/call",
227 params: { name: "gluecron_repo_search", arguments: { q: "x" } },
228 }),
229 });
230 expect(res.status).toBe(401);
231 const wwwAuth = res.headers.get("www-authenticate") || "";
232 expect(wwwAuth).toContain("Bearer");
233 expect(wwwAuth).toContain("resource_metadata=");
234 expect(wwwAuth).toContain("/.well-known/oauth-protected-resource");
b5a7bb3ccanty labs235 // RFC 6750: the ANONYMOUS challenge must NOT carry `error=` — its absence
236 // is what tells the client "no credentials, start first-time auth" rather
237 // than "your token died, refresh it".
238 expect(wwwAuth).not.toContain("error=");
0a6ebcdccanty labs239 const body = (await res.json()) as any;
240 expect(body.error.code).toBe(-32001);
241 expect(body.id).toBe(7); // echoes the request id
242 });
243
244 it("challenges when a batch contains a tools/call while anonymous", async () => {
245 const res = await app.request("/mcp", {
246 method: "POST",
247 headers: { "content-type": "application/json" },
248 body: JSON.stringify([
249 { jsonrpc: "2.0", id: 1, method: "initialize" },
250 {
251 jsonrpc: "2.0",
252 id: 2,
253 method: "tools/call",
254 params: { name: "gluecron_repo_search", arguments: { q: "x" } },
255 },
256 ]),
257 });
258 expect(res.status).toBe(401);
259 expect(res.headers.get("www-authenticate") || "").toContain("resource_metadata=");
260 });
261
262 it("still allows the anonymous handshake (no challenge on tools/list)", async () => {
263 const res = await app.request("/mcp", {
264 method: "POST",
265 headers: { "content-type": "application/json" },
266 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
267 });
268 expect(res.status).toBe(200);
269 });
270});
b5a7bb3ccanty labs271
272describe("HTTP route — POST /mcp invalid bearer challenge (RFC 6750)", () => {
273 // A presented-but-dead bearer (expired / revoked / unknown) must fail
274 // LOUDLY on every method — including the handshake — with
275 // `error="invalid_token"`, so clients run the refresh grant or re-auth
276 // instead of holding a false-healthy connection. Regression guard for the
277 // 2026-07-14 "token expired" reconnect loop.
278 const rpc = (method: string, bearer: string, id = 9) =>
279 app.request("/mcp", {
280 method: "POST",
281 headers: {
282 "content-type": "application/json",
283 authorization: `Bearer ${bearer}`,
284 },
285 body: JSON.stringify({ jsonrpc: "2.0", id, method, params: {} }),
286 });
287
288 const expectInvalidToken = async (res: Response) => {
289 expect(res.status).toBe(401);
290 const wwwAuth = res.headers.get("www-authenticate") || "";
291 expect(wwwAuth).toContain('error="invalid_token"');
292 expect(wwwAuth).toContain("resource_metadata=");
293 const body = (await res.json()) as any;
294 expect(body.error.code).toBe(-32001);
295 expect(String(body.error.message)).toMatch(/expired|invalid/i);
296 };
297
298 it("challenges an unknown glct_ token on tools/call", async () => {
299 await expectInvalidToken(await rpc("tools/call", "glct_deadbeef"));
300 });
301
302 it("challenges an unknown glct_ token on initialize (breaks the reconnect loop)", async () => {
303 await expectInvalidToken(await rpc("initialize", "glct_deadbeef"));
304 });
305
306 it("challenges an unknown glct_ token on tools/list", async () => {
307 await expectInvalidToken(await rpc("tools/list", "glct_deadbeef"));
308 });
309
310 it("challenges an unknown glc_ PAT on initialize", async () => {
311 await expectInvalidToken(await rpc("initialize", "glc_deadbeef"));
312 });
313
314 it("echoes the request id in the invalid_token error", async () => {
315 const res = await rpc("initialize", "glct_deadbeef", 42);
316 const body = (await res.json()) as any;
317 expect(body.id).toBe(42);
318 });
319
320 it("challenges GET /mcp discovery with a dead bearer, plain-JSON body", async () => {
321 const res = await app.request("/mcp", {
322 headers: { authorization: "Bearer glct_deadbeef" },
323 });
324 expect(res.status).toBe(401);
325 expect(res.headers.get("www-authenticate") || "").toContain(
326 'error="invalid_token"'
327 );
328 const body = (await res.json()) as any;
329 expect(body.error).toBe("invalid_token");
330 });
331
332 it("a non-gluecron bearer scheme is ignored (anonymous handshake works)", async () => {
333 // e.g. an unrelated `Bearer xyz` (no glct_/glc_ prefix) must not trip the
334 // invalid-token gate — softAuth only flags gluecron-shaped tokens.
335 const res = await rpc("tools/list", "some-other-token");
336 expect(res.status).toBe(200);
337 });
338});