Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,17 @@ 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-<n>`, or `lore-<n>`), 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. |
| `assets_deny` | Deny a submitted game asset version with constructive feedback. |

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 |
Expand Down
99 changes: 99 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-<n>' 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-<n>', or 'lore-<n>'" },
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-<n>', or 'lore-<n>'), 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.",
Expand Down Expand Up @@ -328,6 +359,36 @@ const PROMPTS = [
}
];

// Lore and Perforce share source_type "Changelist"; Lore identifiers are
// namespaced "lore-<n>", 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-<n>" but its Change stores "<n>".
// 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": {
Expand Down Expand Up @@ -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-<n>', or 'lore-<n>'. 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);
Expand Down
131 changes: 129 additions & 2 deletions test/mcp-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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-<n> 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/);
});
Loading