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.
| 0feb720 | 1 | /** |
| 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 | ||
| 16 | import { describe, it, expect } from "bun:test"; | |
| 17 | import { | |
| 18 | ERR_INVALID_PARAMS, | |
| 19 | ERR_METHOD_NOT_FOUND, | |
| 20 | McpError, | |
| 21 | } from "../lib/mcp"; | |
| 22 | import { defaultTools } from "../lib/mcp-tools"; | |
| 23 | import { expandedTools, __expandedTest } from "../lib/mcp-tools-expanded"; | |
| 24 | ||
| 25 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 26 | ||
| 27 | const anonCtx = { userId: null, scopes: [] }; | |
| 28 | const authedCtx = { userId: "user-fixture-id", scopes: ["repo", "user", "admin"] }; | |
| 29 | const limitedCtx = { userId: "user-fixture-id", scopes: ["repo"] }; | |
| 30 | ||
| 31 | // --------------------------------------------------------------------------- | |
| 32 | // Registry | |
| 33 | // --------------------------------------------------------------------------- | |
| 34 | ||
| 35 | describe("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 | ||
| 90 | describe("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 | ||
| 159 | describe("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 | ||
| 301 | describe("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 | ||
| 335 | describe("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 | ||
| 370 | describe.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 | }); |