Commit0feb720unknown_key
feat(mcp): expand to 50+ tools — every Gluecron action callable from any AI agent
9 files changed+3082−90feb7202ddc0bc3797e67e196f2505a7ea9a1d5e
9 changed files+3082−9
Modifiedextension/gluecron.dxt/manifest.json+48−3View fileUnifiedSplit
@@ -4,8 +4,8 @@
44 "name": "gluecron",
55 "display_name": "Gluecron",
66 "version": "1.0.0",
7 "description": "AI-native git hosting. Claude can open PRs, review code, merge, manage issues, and ship — all on Gluecron.",
8 "long_description": "Gluecron is the git host built around Claude. This extension gives Claude 15 tools to read and write against your Gluecron-hosted repos: open and merge PRs, file and comment on issues, search code, list and read files, get health reports, drive your AI-native development loop end-to-end. No more switching between Claude and a browser tab.",
7 "description": "AI-native git hosting. Claude can open PRs, review code, merge, manage issues, drive autopilot — all on Gluecron.",
8 "long_description": "Gluecron is the git host built around Claude. This extension exposes the full 50+ tool MCP surface: read/write files, open/review/merge PRs, file/label/assign issues, ship specs, run multi-repo refactors, chat with a repo, generate tests, propose migrations, dispatch + cancel workflows, lease agent multiplayer targets, and query semantic + symbol indices. Drive the entire AI-native dev loop end-to-end.",
99 "author": {
1010 "name": "Gluecron",
1111 "url": "https://gluecron.com"
@@ -53,7 +53,52 @@
5353 {"name": "gluecron_list_prs", "description": "List PRs by state."},
5454 {"name": "gluecron_comment_pr", "description": "Comment on a PR."},
5555 {"name": "gluecron_merge_pr", "description": "Merge a PR (gated on branch protection + AI review + risk score)."},
56 {"name": "gluecron_close_pr", "description": "Close a PR without merging."}
56 {"name": "gluecron_close_pr", "description": "Close a PR without merging."},
57 {"name": "gluecron_fork_repo", "description": "Fork a repo to the caller's namespace."},
58 {"name": "gluecron_delete_repo", "description": "Permanently delete a repo (admin)."},
59 {"name": "gluecron_update_repo", "description": "Update description / visibility / default branch."},
60 {"name": "gluecron_search_repos", "description": "Full search of public repos with sort + filters."},
61 {"name": "gluecron_clone_url", "description": "Authed HTTPS clone URL + credential-helper hint."},
62 {"name": "gluecron_label_issue", "description": "Attach labels to an issue (auto-creates missing)."},
63 {"name": "gluecron_unlabel_issue", "description": "Detach a single label from an issue."},
64 {"name": "gluecron_assign_issue", "description": "Assign an issue to a user via assignee:* label."},
65 {"name": "gluecron_search_issues", "description": "Title/body keyword search of issues on a repo."},
66 {"name": "gluecron_request_changes", "description": "Post a 'changes requested' AI-review comment on a PR."},
67 {"name": "gluecron_search_prs", "description": "Title/body keyword search of PRs on a repo."},
68 {"name": "gluecron_open_draft_pr", "description": "Open a draft pull request (is_draft=true)."},
69 {"name": "gluecron_generate_pr_description", "description": "AI commit-message-style description for a diff."},
70 {"name": "gluecron_read_file", "description": "Read a file at a ref (REST /contents wrapper)."},
71 {"name": "gluecron_write_file", "description": "Create/update a file on a branch (utf8 or base64)."},
72 {"name": "gluecron_delete_file", "description": "Delete a file on a branch (optimistic-concurrency by sha)."},
73 {"name": "gluecron_list_tree", "description": "List a directory tree at a ref (recursive supported)."},
74 {"name": "gluecron_get_commit", "description": "Fetch a single commit's metadata."},
75 {"name": "gluecron_create_branch", "description": "Create a new branch ref at an existing sha."},
76 {"name": "gluecron_atomic_multi_file_commit", "description": "Apply a set of writes + deletes as one atomic commit."},
77 {"name": "gluecron_ship_spec", "description": "Drop .gluecron/specs/*.md status:ready for the autopilot."},
78 {"name": "gluecron_voice_to_pr", "description": "Transcript → spec or issue (auto-classified)."},
79 {"name": "gluecron_refactor_across_repos", "description": "Plan + execute a multi-repo refactor."},
80 {"name": "gluecron_explain_repo", "description": "Cached AI explain-this-repo markdown."},
81 {"name": "gluecron_chat_with_repo", "description": "Start a chat with a repo + send the first message."},
82 {"name": "gluecron_chat_continue", "description": "Send another message in an existing repo chat."},
83 {"name": "gluecron_generate_tests", "description": "Generate tests for a PR (follow-up-pr or append-commit)."},
84 {"name": "gluecron_generate_commit_message", "description": "Generate a commit message for a diff."},
85 {"name": "gluecron_generate_release_notes", "description": "Section-bucketed release notes between two tags."},
86 {"name": "gluecron_propose_migration", "description": "Propose a dep-upgrade PR via migration-assistant."},
87 {"name": "gluecron_propose_doc_update", "description": "Doc-drift scan + AI-rewrite PR."},
88 {"name": "gluecron_trigger_workflow", "description": "Dispatch a workflow_dispatch run."},
89 {"name": "gluecron_get_workflow_run", "description": "Fetch a workflow run's metadata + status."},
90 {"name": "gluecron_get_workflow_logs", "description": "Per-job log payload for a workflow run."},
91 {"name": "gluecron_cancel_workflow_run", "description": "Cancel a queued/running workflow run."},
92 {"name": "gluecron_get_preview_url", "description": "Branch-preview URL + status for (repo, branch)."},
93 {"name": "gluecron_provision_pr_sandbox", "description": "Provision (or re-provision) a sandbox for a PR."},
94 {"name": "gluecron_create_agent_session", "description": "Mint an agent-multiplayer session (token returned once)."},
95 {"name": "gluecron_acquire_lease", "description": "Grab an exclusive lease on a target for an agent."},
96 {"name": "gluecron_release_lease", "description": "Release a held lease by id."},
97 {"name": "gluecron_get_agent_budget", "description": "Return spent / cap / remaining cents for an agent."},
98 {"name": "gluecron_semantic_search", "description": "Query the per-repo vector index."},
99 {"name": "gluecron_find_symbol", "description": "Find definitions of a symbol within a repo."},
100 {"name": "gluecron_pr_status_summary", "description": "Compute a one-shot status summary for a PR."},
101 {"name": "gluecron_ai_cost_summary", "description": "Return AI spend rollups (user/repo/agent)."}
57102 ],
58103 "compatibility": {
59104 "claude_desktop": ">=0.10.0",
Modifiedsrc/__tests__/dxt-extension.test.ts+7−5View fileUnifiedSplit
@@ -78,7 +78,7 @@ describe("Block Q1 — .dxt manifest", () => {
7878 expect(raw).not.toMatch(/glct_[A-Za-z0-9_-]+/);
7979 });
8080
81 it("declares all 15 MCP tools, cross-checked against defaultTools()", () => {
81 it("declares every MCP tool, cross-checked against defaultTools()", () => {
8282 const m = loadManifest();
8383 const manifestNames = new Set(m.tools.map((t) => t.name));
8484 const handlerNames = new Set(Object.keys(defaultTools()));
@@ -91,10 +91,12 @@ describe("Block Q1 — .dxt manifest", () => {
9191 for (const name of manifestNames) {
9292 expect(handlerNames.has(name)).toBe(true);
9393 }
94 // Lock the count at 15 so a future tool addition forces this test
95 // (and therefore the manifest) to be updated in lockstep.
96 expect(manifestNames.size).toBe(15);
97 expect(handlerNames.size).toBe(15);
94 // Floor at 50 to guarantee the expanded surface is exposed; the
95 // exact count travels with `defaultTools()` so adding a tool there
96 // and forgetting the manifest still trips the per-name assertions
97 // above.
98 expect(manifestNames.size).toBe(handlerNames.size);
99 expect(manifestNames.size).toBeGreaterThanOrEqual(50);
98100 });
99101});
100102
Addedsrc/__tests__/mcp-tools-expanded.test.ts+380−0View fileUnifiedSplit
@@ -0,0 +1,380 @@
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
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});
Addedsrc/lib/mcp-tools-expanded.ts+2470−0View fileUnifiedSplit
Large file (2,470 lines). Load full file
Modifiedsrc/lib/mcp-tools.ts+32−0View fileUnifiedSplit
@@ -49,6 +49,7 @@ import {
4949 getLatestCachedPrRisk,
5050 type PrRiskScore,
5151} from "./pr-risk";
52import { expandedTools } from "./mcp-tools-expanded";
5253
5354export type McpTool = {
5455 name: string;
@@ -1418,6 +1419,16 @@ function sqlExpr(expr: string) {
14181419// ---------------------------------------------------------------------------
14191420
14201421export function defaultTools(): Record<string, McpToolHandler> {
1422 // The original 15-tool surface (read + K1 write) remains the source of
1423 // truth for the core gate-enforced workflows. The expanded set in
1424 // `mcp-tools-expanded.ts` adds ~40 additional wrappers — each a thin
1425 // adapter over an existing lib helper — so AI agents can drive
1426 // Gluecron end-to-end.
1427 //
1428 // The expanded module imports the `mcp*` helpers re-exported above —
1429 // since those are `const` exports they're populated by the time
1430 // `defaultTools()` is called at request time, so the circular ESM
1431 // import resolves cleanly.
14211432 return {
14221433 [repoSearch.tool.name]: repoSearch,
14231434 [repoReadFile.tool.name]: repoReadFile,
@@ -1435,9 +1446,30 @@ export function defaultTools(): Record<string, McpToolHandler> {
14351446 [commentPr.tool.name]: commentPr,
14361447 [mergePr.tool.name]: mergePr,
14371448 [closePr.tool.name]: closePr,
1449 // Expanded surface (50+ tools total)
1450 ...expandedTools(),
14381451 };
14391452}
14401453
1454// ---------------------------------------------------------------------------
1455// Shared helpers (re-exported so the expanded tool surface in
1456// `mcp-tools-expanded.ts` can reuse the same gating + arg-parsing without
1457// duplicating logic). Each is a thin pure helper or DB lookup — exporting
1458// them keeps the wrapper modules tiny.
1459// ---------------------------------------------------------------------------
1460
1461export {
1462 argString as mcpArgString,
1463 argNumber as mcpArgNumber,
1464 gateWriteAccess as mcpGateWriteAccess,
1465 resolveAccessibleRepo as mcpResolveAccessibleRepo,
1466 requireAuthedCtx as mcpRequireAuthedCtx,
1467 loadPrByNumber as mcpLoadPrByNumber,
1468 loadIssueByNumber as mcpLoadIssueByNumber,
1469 prUrl as mcpPrUrl,
1470 issueUrl as mcpIssueUrl,
1471};
1472
14411473/** Test-only export of internal helpers + per-tool handlers. */
14421474export const __test = {
14431475 argString,
Modifiedsrc/lib/mcp.ts+7−0View fileUnifiedSplit
@@ -54,6 +54,13 @@ export const ERR_INTERNAL = -32603;
5454export type McpContext = {
5555 /** Authenticated user id; null for anonymous (only allowed for some calls). */
5656 userId: string | null;
57 /**
58 * Scopes carried by the auth credential (PAT, OAuth token, session). For
59 * session-cookie users this is `["repo","user","admin"]`. For PATs it's
60 * whatever the token was issued for. Empty array when anonymous.
61 * Optional for back-compat — handlers default to permissive when undefined.
62 */
63 scopes?: string[];
5764};
5865
5966export type McpRouterArgs = {
Modifiedsrc/routes/build-agent-spec.tsx+117−0View fileUnifiedSplit
@@ -395,6 +395,106 @@ Issues: https://gluecron.com/ccantynz/Gluecron.com/issues
395395Contact: ccantynz on gluecron.com
396396═══════════════════════════════════════════════════════════════════════════════`;
397397
398const MCP_TOOLS_TEXT = `═══════════════════════════════════════════════════════════════════════════════
399GLUECRON MCP TOOL SURFACE (50+ tools, model-context-protocol/2025-06-18)
400═══════════════════════════════════════════════════════════════════════════════
401
402Transport: POST /mcp — JSON-RPC 2.0 (single or batch)
403 GET /mcp — discovery handshake
404Auth: same as REST v2 — Bearer glc_… (PAT) or session cookie
405 agent tokens (agt_…) routed via agent-multiplayer
406
407Call shape:
408 {
409 "jsonrpc": "2.0", "id": 1,
410 "method": "tools/call",
411 "params": { "name": "<tool_name>", "arguments": { … } }
412 }
413
414─── READ TOOLS (public, no auth) ────────────────────────────────────────────
415gluecron_repo_search Search public repos by keyword
416gluecron_repo_read_file Read a file at a ref
417gluecron_repo_list_issues List open issues on a repo
418gluecron_repo_explain_codebase Cached AI 'explain this codebase' markdown
419gluecron_repo_health Health score + breakdown for a repo
420
421─── REPOS (write — requires 'repo' or 'admin') ──────────────────────────────
422gluecron_fork_repo Fork a repo to the caller's namespace
423gluecron_delete_repo Permanently delete a repo (admin)
424gluecron_update_repo Description / visibility / default branch
425gluecron_search_repos Full search (sort, limit, filters)
426gluecron_clone_url Authed HTTPS clone URL + helper hint
427
428─── ISSUES (write — requires 'repo') ────────────────────────────────────────
429gluecron_create_issue Open a new issue
430gluecron_comment_issue Add a comment
431gluecron_close_issue Close an open issue (idempotent)
432gluecron_reopen_issue Reopen a closed issue
433gluecron_label_issue Attach labels (auto-creates missing)
434gluecron_unlabel_issue Detach a single label
435gluecron_assign_issue Assign to a user (via assignee:* label)
436gluecron_search_issues Title/body keyword search per repo
437
438─── PULL REQUESTS (write — requires 'repo') ─────────────────────────────────
439gluecron_create_pr Open a PR
440gluecron_open_draft_pr Open a draft PR
441gluecron_get_pr Fetch full PR record
442gluecron_list_prs List PRs by state
443gluecron_search_prs Keyword search on title/body
444gluecron_comment_pr Add a PR comment
445gluecron_request_changes Post an AI-review 'changes requested' comment
446gluecron_merge_pr Merge a PR (enforces every gate + risk score)
447gluecron_close_pr Close without merging (idempotent)
448gluecron_generate_pr_description AI commit-message-style description
449
450─── FILES & GIT PLUMBING (write — requires 'repo') ──────────────────────────
451gluecron_read_file GET /contents wrapper
452gluecron_write_file PUT /contents wrapper (utf8 or base64)
453gluecron_delete_file DELETE /contents wrapper
454gluecron_list_tree GET /tree wrapper (recursive supported)
455gluecron_get_commit GET /commits/:sha wrapper
456gluecron_create_branch Create a new branch ref at a sha
457gluecron_atomic_multi_file_commit blob/tree/commit/ref-update in one call
458 — the killer agent tool
459
460─── AI WORKFLOWS (Gluecron-native — requires 'repo') ────────────────────────
461gluecron_ship_spec Drop .gluecron/specs/*.md status:ready
462gluecron_voice_to_pr Transcript → spec or issue (auto-classified)
463gluecron_refactor_across_repos Plan + execute a multi-repo refactor
464gluecron_explain_repo Cached 'explain this repo' markdown
465gluecron_chat_with_repo Start a chat + send the first message
466gluecron_chat_continue Send another message in a chat
467gluecron_generate_tests AI tests for a PR (follow-up-pr / append)
468gluecron_generate_commit_message Conventional / plain commit message for a diff
469gluecron_generate_release_notes Section-bucketed notes between two tags
470gluecron_propose_migration Dep-upgrade PR via migration-assistant
471gluecron_propose_doc_update Doc-drift scan + PR opens
472
473─── CI / DEPLOYS (write — requires 'repo') ──────────────────────────────────
474gluecron_trigger_workflow workflow_dispatch
475gluecron_get_workflow_run Run status + metadata
476gluecron_get_workflow_logs Per-job log payload (JSON; ZIP via REST)
477gluecron_cancel_workflow_run Cancel queued/running run
478gluecron_get_preview_url Branch-preview URL + status
479gluecron_provision_pr_sandbox Re-provision a PR sandbox
480
481─── AGENTS (multiplayer surface — admin to mint, repo to lease) ─────────────
482gluecron_create_agent_session Mint agent token (returned ONCE) (admin)
483gluecron_acquire_lease Grab an exclusive target lease
484gluecron_release_lease Release a held lease
485gluecron_get_agent_budget spent / cap / remaining cents
486
487─── SEMANTIC ────────────────────────────────────────────────────────────────
488gluecron_semantic_search Vector-index query (Voyage or hash)
489gluecron_find_symbol findDefinitions wrapper
490
491─── INSIGHTS ────────────────────────────────────────────────────────────────
492gluecron_pr_status_summary State + risk + trio verdict for a PR
493gluecron_repo_health (also under READ; included here for parity)
494gluecron_ai_cost_summary Spend rollup (user / repo / agent)
495
496═══════════════════════════════════════════════════════════════════════════════`;
497
398498buildAgentSpec.get("/docs/build-agent-integration", (c) => {
399499 const user = c.get("user");
400500 return c.html(
@@ -450,6 +550,23 @@ buildAgentSpec.get("/docs/build-agent-integration", (c) => {
450550 </div>
451551 </section>
452552
553 {/* ─── MCP tools surface ─────────────────────────────────────── */}
554 <section class="ba-spec-block" aria-labelledby="ba-mcp-title">
555 <header class="ba-spec-head">
556 <div>
557 <p class="ba-spec-title-bar" id="ba-mcp-title">
558 <span class="ba-spec-dot" aria-hidden="true" />
559 MCP tools — every Gluecron action callable from any AI agent
560 </p>
561 </div>
562 </header>
563 <pre class="ba-spec-pre">{MCP_TOOLS_TEXT}</pre>
564 <div class="ba-spec-foot">
565 JSON-RPC 2.0 endpoint: <code>POST https://gluecron.com/mcp</code> —
566 send <code>{"{ method: \"tools/list\" }"}</code> to enumerate at runtime.
567 </div>
568 </section>
569
453570 <div class="ba-spec-callout">
454571 Looking for the full Gluecron platform spec? See{" "}
455572 <a href="/help">/help</a> and{" "}
Modifiedsrc/routes/help.tsx+8−0View fileUnifiedSplit
@@ -1035,6 +1035,14 @@ gluecron hook uninstall commit-msg`}
10351035 — endpoint list, auth scopes, webhook payloads, and the
10361036 <code>ai:build</code> label convention.
10371037 </div>
1038 <div class="help-item">
1039 Prefer model-context-protocol over REST? Same page now
1040 documents the full 50+ <strong>MCP tool surface</strong> at{" "}
1041 <a href="/docs/build-agent-integration#ba-mcp-title">/docs/build-agent-integration</a>{" "}
1042 — every Gluecron action callable from any Claude/Cursor/etc.
1043 tool-use loop. The JSON-RPC endpoint is{" "}
1044 <code>POST /mcp</code>.
1045 </div>
10381046 </div>
10391047 </section>
10401048
Modifiedsrc/routes/mcp.ts+13−1View fileUnifiedSplit
@@ -45,7 +45,19 @@ mcp.get("/mcp", (c) => {
4545
4646mcp.post("/mcp", async (c) => {
4747 const user = c.get("user") ?? null;
48 const ctx = { userId: user?.id ?? null };
48 // Scope derivation mirrors the API-auth middleware:
49 // - OAuth bearer (glct_) → oauthScopes from middleware
50 // - PAT (glc_) → oauthScopes from middleware
51 // - Session cookie → full ["repo","user","admin"]
52 // - Anonymous → []
53 const oauthScopes = c.get("oauthScopes");
54 let scopes: string[] = [];
55 if (Array.isArray(oauthScopes)) {
56 scopes = oauthScopes;
57 } else if (user) {
58 scopes = ["repo", "user", "admin"];
59 }
60 const ctx = { userId: user?.id ?? null, scopes };
4961 const tools = defaultTools();
5062
5163 let body: unknown;
5264