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-tools-expanded.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-tools-expanded.test.tsBlame380 lines · 1 contributor
0feb720Claude1/**
2 * Tests for the expanded MCP tool surface in src/lib/mcp-tools-expanded.ts.
3 *
4 * We focus on:
5 * - Manifest shape: every new tool has a name, description, inputSchema
6 * - Default tools registry merges the expanded set in (≥ 50 total)
7 * - Input validation: each tool rejects malformed args (missing fields)
8 * - Auth gating: write tools throw INVALID_PARAMS without ctx.userId
9 * - Scope gating: admin-grade tools throw INVALID_PARAMS without 'admin'
10 * - A handful of happy-path branches that exercise pure logic without
11 * touching Postgres (uses HAS_DB skipIf where DB is required).
12 *
13 * Mirrors the conventions of `mcp.test.ts` + `mcp-write.test.ts`.
14 */
15
16import { describe, it, expect } from "bun:test";
17import {
18 ERR_INVALID_PARAMS,
19 ERR_METHOD_NOT_FOUND,
20 McpError,
21} from "../lib/mcp";
22import { defaultTools } from "../lib/mcp-tools";
23import { expandedTools, __expandedTest } from "../lib/mcp-tools-expanded";
24
25const HAS_DB = Boolean(process.env.DATABASE_URL);
26
27const anonCtx = { userId: null, scopes: [] };
28const authedCtx = { userId: "user-fixture-id", scopes: ["repo", "user", "admin"] };
29const limitedCtx = { userId: "user-fixture-id", scopes: ["repo"] };
30
31// ---------------------------------------------------------------------------
32// Registry
33// ---------------------------------------------------------------------------
34
35describe("expanded tool registry", () => {
36 it("exports at least 40 tools", () => {
37 const tools = expandedTools();
38 expect(Object.keys(tools).length).toBeGreaterThanOrEqual(40);
39 });
40
41 it("merges into defaultTools() to surpass 50 total", () => {
42 const all = defaultTools();
43 expect(Object.keys(all).length).toBeGreaterThanOrEqual(50);
44 });
45
46 it("every tool name is gluecron_-prefixed", () => {
47 for (const handler of Object.values(expandedTools())) {
48 expect(handler.tool.name).toMatch(/^gluecron_/);
49 }
50 });
51
52 it("every tool has a non-trivial description and object schema", () => {
53 for (const handler of Object.values(expandedTools())) {
54 expect(typeof handler.tool.description).toBe("string");
55 expect(handler.tool.description.length).toBeGreaterThan(10);
56 expect(handler.tool.inputSchema.type).toBe("object");
57 expect(typeof handler.tool.inputSchema.properties).toBe("object");
58 }
59 });
60
61 it("does not clobber any existing 15-tool name", () => {
62 const expandedNames = new Set(Object.keys(expandedTools()));
63 const legacy = [
64 "gluecron_repo_search",
65 "gluecron_repo_read_file",
66 "gluecron_repo_list_issues",
67 "gluecron_repo_explain_codebase",
68 "gluecron_repo_health",
69 "gluecron_create_issue",
70 "gluecron_comment_issue",
71 "gluecron_close_issue",
72 "gluecron_reopen_issue",
73 "gluecron_create_pr",
74 "gluecron_get_pr",
75 "gluecron_list_prs",
76 "gluecron_comment_pr",
77 "gluecron_merge_pr",
78 "gluecron_close_pr",
79 ];
80 for (const name of legacy) {
81 expect(expandedNames.has(name)).toBe(false);
82 }
83 });
84});
85
86// ---------------------------------------------------------------------------
87// Input validation
88// ---------------------------------------------------------------------------
89
90describe("input validation — malformed args throw McpError", () => {
91 // We assert one schema-validation behaviour per tool: missing required
92 // argument → McpError. Reading happens before any DB call so these
93 // never need a live DB.
94 const malformedCases: Array<{ name: string; args: Record<string, unknown> }> = [
95 { name: "fork_repo", args: {} },
96 { name: "delete_repo", args: { owner: "x" } },
97 { name: "update_repo", args: {} },
98 { name: "search_repos", args: {} },
99 { name: "clone_url", args: { owner: "x" } },
100 { name: "label_issue", args: { owner: "x", repo: "y" } },
101 { name: "unlabel_issue", args: { owner: "x", repo: "y", number: 1 } },
102 { name: "assign_issue", args: { owner: "x", repo: "y" } },
103 { name: "search_issues", args: { owner: "x", repo: "y" } },
104 { name: "request_changes", args: { owner: "x", repo: "y" } },
105 { name: "search_prs", args: { owner: "x", repo: "y" } },
106 { name: "open_draft_pr", args: { owner: "x", repo: "y" } },
107 { name: "generate_pr_description", args: {} },
108 { name: "read_file", args: { owner: "x", repo: "y" } },
109 { name: "write_file", args: { owner: "x", repo: "y" } },
110 { name: "delete_file", args: { owner: "x", repo: "y" } },
111 { name: "list_tree", args: { owner: "x" } },
112 { name: "get_commit", args: { owner: "x", repo: "y" } },
113 { name: "create_branch", args: { owner: "x", repo: "y" } },
114 { name: "atomic_multi_file_commit", args: { owner: "x", repo: "y" } },
115 { name: "ship_spec", args: { owner: "x", repo: "y" } },
116 { name: "voice_to_pr", args: { owner: "x", repo: "y" } },
117 { name: "refactor_across_repos", args: {} },
118 { name: "explain_repo", args: {} },
119 { name: "chat_with_repo", args: { owner: "x", repo: "y" } },
120 { name: "chat_continue", args: {} },
121 { name: "generate_tests", args: { owner: "x", repo: "y" } },
122 { name: "generate_commit_message", args: {} },
123 { name: "generate_release_notes", args: { owner: "x", repo: "y" } },
124 { name: "propose_migration", args: { owner: "x", repo: "y" } },
125 { name: "propose_doc_update", args: { owner: "x" } },
126 { name: "trigger_workflow", args: { owner: "x", repo: "y" } },
127 { name: "get_workflow_run", args: { owner: "x", repo: "y" } },
128 { name: "get_workflow_logs", args: { owner: "x", repo: "y" } },
129 { name: "cancel_workflow_run", args: { owner: "x", repo: "y" } },
130 { name: "get_preview_url", args: { owner: "x", repo: "y" } },
131 { name: "provision_pr_sandbox", args: { owner: "x", repo: "y" } },
132 { name: "create_agent_session", args: {} },
133 { name: "acquire_lease", args: {} },
134 { name: "release_lease", args: {} },
135 { name: "get_agent_budget", args: {} },
136 { name: "semantic_search", args: { owner: "x", repo: "y" } },
137 { name: "find_symbol", args: { owner: "x", repo: "y" } },
138 { name: "pr_status_summary", args: { owner: "x", repo: "y" } },
139 ];
140
141 for (const tc of malformedCases) {
142 it(`gluecron_${tc.name} rejects missing required args`, async () => {
143 const tools = expandedTools();
144 // The malformed test cases above name the tool sans `gluecron_`
145 // prefix so the tool name and the case name line up.
146 const handler = tools[`gluecron_${tc.name}`];
147 expect(handler).toBeDefined();
148 // We always pass an authed ctx so the arg-validation path is the
149 // *first* failure point — not the auth gate.
150 await expect(handler.run(tc.args, authedCtx)).rejects.toThrow(McpError);
151 });
152 }
153});
154
155// ---------------------------------------------------------------------------
156// Auth gates
157// ---------------------------------------------------------------------------
158
159describe("auth gates — write tools reject anonymous callers", () => {
160 const writeTools = [
161 {
162 name: "gluecron_fork_repo",
163 args: { owner: "a", repo: "b" },
164 },
165 {
166 name: "gluecron_delete_repo",
167 args: { owner: "a", repo: "b" },
168 },
169 {
170 name: "gluecron_update_repo",
171 args: { owner: "a", repo: "b", description: "x" },
172 },
173 {
174 name: "gluecron_label_issue",
175 args: { owner: "a", repo: "b", number: 1, labels: ["bug"] },
176 },
177 {
178 name: "gluecron_unlabel_issue",
179 args: { owner: "a", repo: "b", number: 1, label: "bug" },
180 },
181 {
182 name: "gluecron_assign_issue",
183 args: { owner: "a", repo: "b", number: 1, assignee: "x" },
184 },
185 {
186 name: "gluecron_request_changes",
187 args: { owner: "a", repo: "b", number: 1, body: "x" },
188 },
189 {
190 name: "gluecron_open_draft_pr",
191 args: { owner: "a", repo: "b", title: "t", head_branch: "h" },
192 },
193 {
194 name: "gluecron_write_file",
195 args: { owner: "a", repo: "b", path: "x", branch: "b", message: "m", content: "y" },
196 },
197 {
198 name: "gluecron_delete_file",
199 args: { owner: "a", repo: "b", path: "x", branch: "b", message: "m", sha: "0".repeat(40) },
200 },
201 {
202 name: "gluecron_create_branch",
203 args: { owner: "a", repo: "b", branch: "n", sha: "0".repeat(40) },
204 },
205 {
206 name: "gluecron_atomic_multi_file_commit",
207 args: {
208 owner: "a",
209 repo: "b",
210 branch: "n",
211 message: "m",
212 changes: [{ path: "f", content: "x" }],
213 },
214 },
215 {
216 name: "gluecron_ship_spec",
217 args: { owner: "a", repo: "b", title: "t", body: "x" },
218 },
219 {
220 name: "gluecron_voice_to_pr",
221 args: { owner: "a", repo: "b", transcript: "hi" },
222 },
223 {
224 name: "gluecron_refactor_across_repos",
225 args: { description: "x" },
226 },
227 {
228 name: "gluecron_chat_with_repo",
229 args: { owner: "a", repo: "b", message: "hi" },
230 },
231 {
232 name: "gluecron_chat_continue",
233 args: { chat_id: "x", message: "hi" },
234 },
235 {
236 name: "gluecron_generate_tests",
237 args: { owner: "a", repo: "b", number: 1 },
238 },
239 {
240 name: "gluecron_propose_migration",
241 args: {
242 owner: "a",
243 repo: "b",
244 dependency: "d",
245 from_version: "1",
246 to_version: "2",
247 base_sha: "x",
248 },
249 },
250 {
251 name: "gluecron_propose_doc_update",
252 args: { owner: "a", repo: "b" },
253 },
254 {
255 name: "gluecron_trigger_workflow",
256 args: { owner: "a", repo: "b", filename: "ci.yml" },
257 },
258 {
259 name: "gluecron_cancel_workflow_run",
260 args: { owner: "a", repo: "b", run_id: "x" },
261 },
262 {
263 name: "gluecron_provision_pr_sandbox",
264 args: { owner: "a", repo: "b", number: 1 },
265 },
266 {
267 name: "gluecron_create_agent_session",
268 args: { name: "x" },
269 },
270 {
271 name: "gluecron_acquire_lease",
272 args: { agent_session_id: "x", target_type: "y", target_id: "z" },
273 },
274 {
275 name: "gluecron_release_lease",
276 args: { lease_id: "x" },
277 },
278 {
279 name: "gluecron_get_agent_budget",
280 args: { agent_session_id: "x" },
281 },
282 {
283 name: "gluecron_ai_cost_summary",
284 args: {},
285 },
286 ];
287
288 for (const t of writeTools) {
289 it(`${t.name} throws on anonymous ctx`, async () => {
290 const tools = expandedTools();
291 const handler = tools[t.name];
292 await expect(handler.run(t.args, anonCtx)).rejects.toThrow(McpError);
293 });
294 }
295});
296
297// ---------------------------------------------------------------------------
298// Scope gates
299// ---------------------------------------------------------------------------
300
301describe("scope gates", () => {
302 it("delete_repo requires 'admin' scope", async () => {
303 const { deleteRepo } = __expandedTest;
304 await expect(
305 deleteRepo.run({ owner: "a", repo: "b" }, limitedCtx)
306 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
307 });
308
309 it("create_agent_session requires 'admin' scope", async () => {
310 const { createAgentSession } = __expandedTest;
311 await expect(
312 createAgentSession.run({ name: "agent-x" }, limitedCtx)
313 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
314 });
315
316 it("requireScope helper passes when 'admin' is held", () => {
317 const { requireScope } = __expandedTest;
318 expect(() =>
319 requireScope({ userId: "u", scopes: ["admin"] }, "repo", "x")
320 ).not.toThrow();
321 });
322
323 it("requireScope helper passes when undefined scopes (legacy permissive)", () => {
324 const { requireScope } = __expandedTest;
325 expect(() =>
326 requireScope({ userId: "u" }, "admin", "x")
327 ).not.toThrow();
328 });
329});
330
331// ---------------------------------------------------------------------------
332// Pure-logic happy paths
333// ---------------------------------------------------------------------------
334
335describe("pure-logic tools", () => {
336 it("generate_commit_message returns a subject + body", async () => {
337 const { generateCommitMessageTool } = __expandedTest;
338 const out = (await generateCommitMessageTool.run(
339 { diff: "diff --git a/x b/x\n+hello\n" },
340 authedCtx
341 )) as { subject: string; body: string };
342 expect(typeof out.subject).toBe("string");
343 expect(out.subject.length).toBeGreaterThan(0);
344 });
345
346 it("generate_pr_description routes through ai-commit-message", async () => {
347 const { generatePrDescription } = __expandedTest;
348 const out = (await generatePrDescription.run(
349 { diff: "diff --git a/x b/x\n+hi\n" },
350 authedCtx
351 )) as { subject: string };
352 expect(typeof out.subject).toBe("string");
353 });
354
355 it.skipIf(!HAS_DB)("clone_url throws METHOD_NOT_FOUND for missing repo", async () => {
356 // resolveAccessibleRepo is the first gate; a non-existent repo
357 // surfaces as a method_not_found error code regardless of caller
358 // auth (privacy contract).
359 const { cloneUrl } = __expandedTest;
360 await expect(
361 cloneUrl.run({ owner: "nobody-xyz-fixture", repo: "nope" }, anonCtx)
362 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
363 });
364});
365
366// ---------------------------------------------------------------------------
367// DB-backed smoke (HAS_DB gated)
368// ---------------------------------------------------------------------------
369
370describe.skipIf(!HAS_DB)("DB-backed smoke checks", () => {
371 it("search_repos runs against the live DB and returns a shaped payload", async () => {
372 const { searchRepos } = __expandedTest;
373 const out = (await searchRepos.run(
374 { query: "test", limit: 1 },
375 anonCtx
376 )) as { total: number; repos: unknown[] };
377 expect(typeof out.total).toBe("number");
378 expect(Array.isArray(out.repos)).toBe(true);
379 });
380});