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.tsBlame212 lines · 1 contributor
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});