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.
| 2c2163e | 1 | /** |
| 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 | ||
| 13 | import { describe, it, expect } from "bun:test"; | |
| 14 | import { | |
| 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"; | |
| 23 | import { defaultTools, __test } from "../lib/mcp-tools"; | |
| 24 | import app from "../app"; | |
| 25 | ||
| 26 | const tools = defaultTools(); | |
| 27 | const ctx = { userId: null }; | |
| 28 | ||
| 29 | describe("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 | ||
| 44 | describe("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 | ||
| 69 | describe("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 | ||
| 87 | describe("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 | ||
| 112 | describe("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 | ||
| 134 | describe("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 | ||
| 523ddad | 146 | describe("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 | ||
| 2c2163e | 180 | describe("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 | ||
| 191 | describe("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 | }); | |
| 0a6ebcd | 247 | |
| 248 | describe("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"); | |
| b5a7bb3 | 269 | // 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="); | |
| 0a6ebcd | 273 | 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 | }); | |
| b5a7bb3 | 305 | |
| 306 | describe("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 | }); |