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.tsBlame266 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");
235 const body = (await res.json()) as any;
236 expect(body.error.code).toBe(-32001);
237 expect(body.id).toBe(7); // echoes the request id
238 });
239
240 it("challenges when a batch contains a tools/call while anonymous", async () => {
241 const res = await app.request("/mcp", {
242 method: "POST",
243 headers: { "content-type": "application/json" },
244 body: JSON.stringify([
245 { jsonrpc: "2.0", id: 1, method: "initialize" },
246 {
247 jsonrpc: "2.0",
248 id: 2,
249 method: "tools/call",
250 params: { name: "gluecron_repo_search", arguments: { q: "x" } },
251 },
252 ]),
253 });
254 expect(res.status).toBe(401);
255 expect(res.headers.get("www-authenticate") || "").toContain("resource_metadata=");
256 });
257
258 it("still allows the anonymous handshake (no challenge on tools/list)", async () => {
259 const res = await app.request("/mcp", {
260 method: "POST",
261 headers: { "content-type": "application/json" },
262 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
263 });
264 expect(res.status).toBe(200);
265 });
266});