diff --git a/README.md b/README.md index 821c299..cb0a3b1 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ The server refuses to send a stored credential to a host other than the one it w | `builds_list` | List recent CI/CD engine build runs and cook statuses. | | `builds_get` | Get step timings, exit codes, and commit metadata for a build run. | | `builds_investigate_failure` | Trigger or fetch AI failure investigation for a failed build, with root cause and blame attribution. | +| `changes_list` | List a project's changes (git commits, Perforce and Lore changelists), filterable by source, identifier, and update time. | +| `changes_get` | Get one change with its files, approvals, linked builds, and tasks. Takes a change id or a commit reference (full SHA, a Perforce build's `p4-`, or `lore-`), so a build's `commit_hash` from `builds_get` resolves to its change. | | `assets_list_pending` | List game assets awaiting producer or art lead approval. | | `assets_get_details` | Inspect an asset's polygon count, texture resolution, preview links, and approval history. | | `assets_approve` | Approve a submitted game asset version. | @@ -87,6 +89,8 @@ The server refuses to send a stored credential to a host other than the one it w Every tool takes a `project_id` (except `projects_list`), which accepts either a numeric project ID or a project name. +The `changes_*` tools need the `read:changes` scope. A token issued before that scope was added to the CLI login set does not carry it and gets `insufficient_scope`: run `butter auth login` again, or add the scope to your token in account settings. + ## Prompts | Prompt | Description | diff --git a/index.js b/index.js index 858633a..08c1cfd 100644 --- a/index.js +++ b/index.js @@ -245,6 +245,37 @@ const TOOLS = [ required: ["project_id", "build_id"] } }, + { + name: "changes_list", + description: + "List a project's changes (git commits, Perforce and Lore changelists), newest first. To find the change a build came from, pass the build's commit_hash (from builds_get) as identifier; a Perforce build's 'p4-' is accepted. Orphaned (ghost) commits are excluded unless orphaned is true. Needs the read:changes scope.", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string", description: "Project ID or name" }, + source: { type: "string", enum: ["lore", "git", "perforce"], description: "Only changes from this source" }, + identifier: { type: "string", description: "Exact commit SHA, changelist number, 'p4-', or 'lore-'" }, + updated_since: { type: "string", description: "ISO8601 date or timestamp, e.g. 2026-09-01" }, + orphaned: { type: "boolean", description: "Include orphaned (ghost) commits" }, + limit: { type: "number", default: 20, description: "Page size, 1-50" }, + cursor: { type: "string", description: "pagination.next_cursor from a previous call" } + }, + required: ["project_id"] + } + }, + { + name: "changes_get", + description: + "Get one change with its files, approval counts, linked build runs, latest build, and task ids. change_id is either the numeric id from changes_list or a commit reference (full git SHA, a Perforce build's 'p4-', or 'lore-'), so a build's commit_hash from builds_get resolves straight to its change. Needs the read:changes scope.", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string", description: "Project ID or name" }, + change_id: { type: "string", description: "Change id (all digits) or commit reference" } + }, + required: ["project_id", "change_id"] + } + }, { name: "assets_list_pending", description: "List game assets (textures, meshes, audio, shaders) awaiting producer or art lead approval.", @@ -328,6 +359,36 @@ const PROMPTS = [ } ]; +// Lore and Perforce share source_type "Changelist"; Lore identifiers are +// namespaced "lore-", Perforce ones are bare numbers. +const CHANGE_SOURCES = { + git: { sourceType: "GitCommit" }, + perforce: { sourceType: "Changelist", keep: (c) => c.source_type === "Changelist" && !String(c.identifier).startsWith("lore-") }, + lore: { sourceType: "Changelist", keep: (c) => c.source_type === "Changelist" && String(c.identifier).startsWith("lore-") } +}; + +// A Perforce BuildRun's commit_hash is "p4-" but its Change stores "". +// The identifier filter is exact and unvalidated, so the prefix has to be +// stripped here or the lookup silently returns nothing. +function changeLookupFor(ref) { + const m = /^p4-(\d+)$/.exec(ref); + if (m) return { identifier: m[1], sourceType: "Changelist" }; + return { identifier: ref, sourceType: null }; +} + +async function changesRequest(endpoint) { + try { + return await apiRequest("GET", endpoint); + } catch (err) { + if (err.statusCode === 403 && err.response && err.response.required_scope === "read:changes") { + throw new Error( + "insufficient_scope: this token does not carry read:changes. Tokens issued before the changes tools shipped never got it; run `butter auth login` again to reissue." + ); + } + throw err; + } +} + async function handleToolCall(name, args) { switch (name) { case "projects_list": { @@ -374,6 +435,44 @@ async function handleToolCall(name, args) { const res = await apiRequest("POST", `/api/v1/projects/${args.project_id}/build_runs/${args.build_id}/investigate`); return res; } + case "changes_list": { + const query = new URLSearchParams(); + let keep = null; + if (args.source) { + const source = CHANGE_SOURCES[args.source]; + if (!source) throw new Error(`Unknown source "${args.source}". Use lore, git, or perforce.`); + query.set("source_type", source.sourceType); + keep = source.keep || null; + } + if (args.identifier) { + const lookup = changeLookupFor(String(args.identifier)); + query.set("identifier", lookup.identifier); + if (lookup.sourceType && !args.source) query.set("source_type", lookup.sourceType); + } + if (args.updated_since) query.set("updated_since", args.updated_since); + if (args.orphaned) query.set("orphaned", "true"); + if (args.limit) query.set("limit", args.limit.toString()); + if (args.cursor) query.set("cursor", args.cursor); + const res = await changesRequest(`/api/v1/projects/${args.project_id}/changes?${query.toString()}`); + return keep ? { ...res, changes: res.changes.filter(keep) } : res; + } + case "changes_get": { + const ref = String(args.change_id); + let changeId = ref; + if (!/^\d+$/.test(ref)) { + const lookup = changeLookupFor(ref); + const query = new URLSearchParams({ identifier: lookup.identifier, orphaned: "true" }); + if (lookup.sourceType) query.set("source_type", lookup.sourceType); + const found = await changesRequest(`/api/v1/projects/${args.project_id}/changes?${query.toString()}`); + if (!found.changes || found.changes.length === 0) { + throw new Error( + `No change with identifier "${lookup.identifier}" in project ${args.project_id}. The match is exact: use the full commit SHA, 'p4-', or 'lore-'. A bare Perforce changelist number is read as a change id; use changes_list with identifier instead.` + ); + } + changeId = found.changes[0].id; + } + return changesRequest(`/api/v1/projects/${args.project_id}/changes/${changeId}`); + } case "assets_list_pending": { const query = new URLSearchParams({ pending_approval: "true" }); if (args.asset_type) query.set("asset_type", args.asset_type); diff --git a/test/mcp-server.test.js b/test/mcp-server.test.js index c528e51..db5385d 100644 --- a/test/mcp-server.test.js +++ b/test/mcp-server.test.js @@ -127,11 +127,11 @@ test("initialize handshake reports the unscoped package name", async () => { } }); -test("tools/list enumerates all twelve tools", async () => { +test("tools/list enumerates all fourteen tools", async () => { const home = mkHome(); try { const response = await runMcpRequest(home, { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }); - assert.equal(response.result.tools.length, 12); + assert.equal(response.result.tools.length, 14); const names = response.result.tools.map((t) => t.name); assert.deepEqual( names, @@ -144,6 +144,8 @@ test("tools/list enumerates all twelve tools", async () => { "builds_list", "builds_get", "builds_investigate_failure", + "changes_list", + "changes_get", "assets_list_pending", "assets_get_details", "assets_approve", @@ -189,3 +191,128 @@ test("refuses to send a stored credential to a host different from the one it wa fs.rmSync(home, { recursive: true, force: true }); } }); + +// -- changes_list / changes_get --------------------------------------------- +// +// The changes API had no MCP tool. These drive the real server over stdio +// against a routed HTTP listener, and assert on the requests it sent as well +// as what it returned. + +const http = require("node:http"); + +function startApiServer(routes) { + const requests = []; + const server = http.createServer((req, res) => { + requests.push({ method: req.method, url: req.url }); + const route = routes[`${req.method} ${req.url.split("?")[0]}`]; + const status = route ? route.status || 200 : 404; + const body = JSON.stringify(route ? route.body : { error: "not_found" }); + res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); + res.end(body); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve({ server, port: server.address().port, requests })); + }); +} + +async function callTool(routes, name, args) { + const { server, port, requests } = await startApiServer(routes); + const home = mkHome(); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const response = await runMcpRequest(home, { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args } + }); + return { result: response.result, requests }; + } finally { + await stopCaptureServer(server); + fs.rmSync(home, { recursive: true, force: true }); + } +} + +const CHANGES = [ + { id: 456, identifier: "207", source_type: "Changelist" }, + { id: 457, identifier: "lore-12", source_type: "Changelist" } +]; +const CHANGE_DETAIL = { id: 456, identifier: "207", source_type: "Changelist", build_runs: [{ id: "b-1", commit_hash: "p4-207" }] }; + +test("changes_list returns a project's changes and maps its arguments onto the API", async () => { + const { result, requests } = await callTool( + { "GET /api/v1/projects/108/changes": { body: { changes: CHANGES, pagination: { has_more: false } } } }, + "changes_list", + { project_id: "108", updated_since: "2026-09-01", limit: 5, orphaned: true, cursor: "abc" } + ); + assert.notEqual(result.isError, true, result.content[0].text); + assert.deepEqual(JSON.parse(result.content[0].text).changes, CHANGES); + const params = new URL(requests[0].url, "http://x").searchParams; + assert.equal(params.get("updated_since"), "2026-09-01"); + assert.equal(params.get("limit"), "5"); + assert.equal(params.get("orphaned"), "true"); + assert.equal(params.get("cursor"), "abc"); +}); + +test("changes_list source=lore keeps only Lore changelists", async () => { + const { result, requests } = await callTool( + { "GET /api/v1/projects/108/changes": { body: { changes: CHANGES, pagination: { has_more: false } } } }, + "changes_list", + { project_id: "108", source: "lore" } + ); + assert.deepEqual(JSON.parse(result.content[0].text).changes.map((c) => c.id), [457]); + assert.equal(new URL(requests[0].url, "http://x").searchParams.get("source_type"), "Changelist"); +}); + +test("changes_get with a numeric id fetches that change directly", async () => { + const { result, requests } = await callTool( + { "GET /api/v1/projects/108/changes/456": { body: CHANGE_DETAIL } }, + "changes_get", + { project_id: "108", change_id: "456" } + ); + assert.deepEqual(JSON.parse(result.content[0].text), CHANGE_DETAIL); + assert.equal(requests.length, 1); + assert.equal(requests[0].url, "/api/v1/projects/108/changes/456"); +}); + +test("changes_get resolves a Perforce build's p4- commit to its change", async () => { + const { result, requests } = await callTool( + { + "GET /api/v1/projects/108/changes": { body: { changes: [CHANGES[0]], pagination: { has_more: false } } }, + "GET /api/v1/projects/108/changes/456": { body: CHANGE_DETAIL } + }, + "changes_get", + { project_id: "108", change_id: "p4-207" } + ); + assert.deepEqual(JSON.parse(result.content[0].text), CHANGE_DETAIL); + const params = new URL(requests[0].url, "http://x").searchParams; + assert.equal(params.get("identifier"), "207", "the change stores the bare number, not p4-207"); + assert.equal(params.get("source_type"), "Changelist"); + assert.equal(requests[1].url, "/api/v1/projects/108/changes/456"); +}); + +test("changes_get with an unknown commit is an error, not an empty result", async () => { + const { result } = await callTool( + { "GET /api/v1/projects/108/changes": { body: { changes: [], pagination: { has_more: false } } } }, + "changes_get", + { project_id: "108", change_id: "deadbeef" } + ); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /No change with identifier "deadbeef"/); +}); + +test("a token without read:changes is told to log in again", async () => { + const { result } = await callTool( + { + "GET /api/v1/projects/108/changes": { + status: 403, + body: { error: "insufficient_scope", required_scope: "read:changes", token_scopes: ["read:builds"] } + } + }, + "changes_list", + { project_id: "108" } + ); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /read:changes/); + assert.match(result.content[0].text, /butter auth login/); +});