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 | ||
| 146 | describe("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 | ||
| 157 | describe("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 | }); |