Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

MCP tool results link to what they made or read #327

Mergedtest-botagenton behalf of cnbrkdmrcimerged into main from claude/mcp-links
3 files changed+59−27base f5f3f60 → head 2469998
modifiedapps/diffinery/docs/agents/mcp.md+2−0View file
@@ -369,6 +369,8 @@
369369 }
370370 ```
371371
372+Results about something with a page on the web carry its address as `html_url`, so an agent can link people to what it made or read. This covers branches (`create_branch`), commits (`commit_files`), issues and pull requests (`create_issue`, `update_issue`, `get_issue`, `list_issues`, `create_pull_request`, `get_pull_request`, `list_pull_requests`), comments (`comment`), workflow runs (`list_workflow_runs`, `get_workflow_run` and each of its jobs, `dispatch_workflow`, `rerun_workflow`, `rerun_job`), releases and imports.
373+
372374 When a tool fails, the call still succeeds at the JSON-RPC level, with `isError: true` and the error message as text. The messages are the same as the REST API's:
373375
374376 ```json
modifiedapps/diffinery/src/mcp/server.ts+46−27View file
@@ -59,6 +59,8 @@
5959 };
6060 // A transferred or renamed repository is served under its old owner/name too.
6161 const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min, { follow: true });
62+/** A page of the repository on the web, for the `html_url` results carry so an agent can link people to what it made or read. */
63+const web = (c: AppContext, repo: { owner_login: string; name: string }, path = "") => `${c.env.PUBLIC_URL}/${repo.owner_login}/${repo.name}${path}`;
6264 const OWNER_REPO = {
6365 owner: { type: "string", description: "Repository owner (user or org login)" },
6466 repo: { type: "string", description: "Repository name" },
@@ -125,11 +127,11 @@
125127 input: { ...OWNER_REPO, branch: { type: "string" }, from: { type: "string", description: "Default: the default branch" } },
126128 required: ["owner", "repo", "branch"],
127129 run: async (c, a) => {
128- const { stub, caller } = await repoOf(c, a, "write");
130+ const { stub, caller, repo } = await repoOf(c, a, "write");
129131 const sha = await stub.resolve(optStr(a, "from") ?? "HEAD");
130132 if (!sha) throw new HttpError(404, "source ref not found");
131133 await stub.createRef(`refs/heads/${str(a, "branch")}`, sha, caller);
132- return { branch: str(a, "branch"), sha };
134+ return { branch: str(a, "branch"), sha, html_url: web(c, repo, `/tree/${encodeURI(str(a, "branch"))}`) };
133135 },
134136 },
135137 commit_files: {
@@ -146,7 +148,7 @@
146148 },
147149 required: ["owner", "repo", "branch", "message"],
148150 run: async (c, a) => {
149- const { stub, caller } = await repoOf(c, a, "write");
151+ const { stub, caller, repo } = await repoOf(c, a, "write");
150152 const files = Array.isArray(a.files) ? (a.files as Array<{ path: string; content?: string; encoding?: string; executable?: boolean }>) : [];
151153 const deletes = Array.isArray(a.deletes) ? (a.deletes as string[]) : [];
152154 if (!files.length && !deletes.length) throw new HttpError(422, "nothing to commit");
@@ -158,7 +160,8 @@
158160 })),
159161 ...deletes.map((p) => ({ path: String(p), delete: true })),
160162 ];
161- return stub.commitFiles({ branch: str(a, "branch"), message: str(a, "message"), changes, expectedHeadSha: optStr(a, "expected_head_sha"), createBranchFrom: optStr(a, "create_branch_from") }, caller);
163+ const out = await stub.commitFiles({ branch: str(a, "branch"), message: str(a, "message"), changes, expectedHeadSha: optStr(a, "expected_head_sha"), createBranchFrom: optStr(a, "create_branch_from") }, caller);
164+ return { ...out, html_url: web(c, repo, `/commit/${out.sha}`) };
162165 },
163166 },
164167 compare: {
@@ -174,9 +177,9 @@
174177 required: ["owner", "repo"],
175178 readOnly: true,
176179 run: async (c, a) => {
177- const { stub } = await repoOf(c, a);
180+ const { stub, repo } = await repoOf(c, a);
178181 const { items, total } = await stub.issues({ kind: "pull", state: (optStr(a, "state") as "open") ?? "open", limit: 100, hide: await hiddenAuthors(c) });
179- return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, head: i.pull?.head, base: i.pull?.base, draft: i.pull?.draft, merged: !!i.pull?.mergedAt })) };
182+ return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, head: i.pull?.head, base: i.pull?.base, draft: i.pull?.draft, merged: !!i.pull?.mergedAt, html_url: web(c, repo, `/pull/${i.number}`) })) };
180183 },
181184 },
182185 get_pull_request: {
@@ -186,12 +189,12 @@
186189 required: ["owner", "repo", "number"],
187190 readOnly: true,
188191 run: async (c, a) => {
189- const { stub, caller } = await repoOf(c, a);
192+ const { stub, caller, repo } = await repoOf(c, a);
190193 const n = int(a, "number");
191194 // An agent may not merge into a protected branch itself, but auto-merge may land it for the agent: say what each needs.
192195 const [detail, mergeability, automatic] = await Promise.all([stub.issue(n, await hiddenAuthors(c)), stub.mergeability(n, caller).catch(() => null), isPerson(caller) ? null : stub.mergeability(n, caller, { automatic: true }).catch(() => null)]);
193196 const files = a.include_diff ? await stub.pullFiles(n) : undefined;
194- return { ...detail, mergeability, ...(automatic ? { auto_merge_blockers: automatic.blockers } : {}), files };
197+ return { ...detail, html_url: web(c, repo, `/pull/${n}`), mergeability, ...(automatic ? { auto_merge_blockers: automatic.blockers } : {}), files };
195198 },
196199 },
197200 create_pull_request: {
@@ -200,8 +203,8 @@
200203 input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, head: { type: "string" }, base: { type: "string" }, draft: { type: "boolean" }, intent: { type: "string" }, reviewers: { type: "array", items: { type: "string" } }, auto_merge: { type: "boolean" } },
201204 required: ["owner", "repo", "title", "head"],
202205 run: async (c, a) => {
203- const { stub, caller } = await repoOf(c, a, "write");
204- return stub.createPull(
206+ const { stub, caller, repo } = await repoOf(c, a, "write");
207+ const pull = await stub.createPull(
205208 {
206209 title: str(a, "title"),
207210 body: optStr(a, "body"),
@@ -214,6 +217,7 @@
214217 },
215218 caller,
216219 );
220+ return { ...pull, html_url: web(c, repo, `/pull/${pull.number}`) };
217221 },
218222 },
219223 comment: {
@@ -221,8 +225,9 @@
221225 input: { ...OWNER_REPO, number: { type: "integer" }, body: { type: "string" } },
222226 required: ["owner", "repo", "number", "body"],
223227 run: async (c, a) => {
224- const { stub, caller } = await repoOf(c, a, "triage");
225- return stub.addComment(int(a, "number"), str(a, "body"), caller);
228+ const { stub, caller, repo } = await repoOf(c, a, "triage");
229+ const comment = await stub.addComment(int(a, "number"), str(a, "body"), caller);
230+ return { ...comment, html_url: web(c, repo, `/issues/${int(a, "number")}#c-${comment.id}`) };
226231 },
227232 },
228233 review_pull_request: {
@@ -294,9 +299,9 @@
294299 required: ["owner", "repo"],
295300 readOnly: true,
296301 run: async (c, a) => {
297- const { stub } = await repoOf(c, a);
302+ const { stub, repo } = await repoOf(c, a);
298303 const { items, total } = await stub.issues({ kind: "issue", state: (optStr(a, "state") as "open") ?? "open", q: optStr(a, "q"), label: optStr(a, "label"), limit: 100, hide: await hiddenAuthors(c) });
299- return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, labels: i.labels })) };
304+ return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, labels: i.labels, html_url: web(c, repo, `/issues/${i.number}`) })) };
300305 },
301306 },
302307 get_issue: {
@@ -304,15 +309,19 @@
304309 input: { ...OWNER_REPO, number: { type: "integer" } },
305310 required: ["owner", "repo", "number"],
306311 readOnly: true,
307- run: async (c, a) => (await repoOf(c, a)).stub.issue(int(a, "number"), await hiddenAuthors(c)),
312+ run: async (c, a) => {
313+ const { stub, repo } = await repoOf(c, a);
314+ return { ...(await stub.issue(int(a, "number"), await hiddenAuthors(c))), html_url: web(c, repo, `/issues/${int(a, "number")}`) };
315+ },
308316 },
309317 create_issue: {
310318 description: "Open an issue.",
311319 input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, labels: { type: "array", items: { type: "string" } } },
312320 required: ["owner", "repo", "title"],
313321 run: async (c, a) => {
314- const { stub, caller } = await repoOf(c, a, "read");
315- return stub.createIssue({ title: str(a, "title"), body: optStr(a, "body"), labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller);
322+ const { stub, caller, repo } = await repoOf(c, a, "read");
323+ const issue = await stub.createIssue({ title: str(a, "title"), body: optStr(a, "body"), labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller);
324+ return { ...issue, html_url: web(c, repo, `/issues/${issue.number}`) };
316325 },
317326 },
318327 update_issue: {
@@ -320,8 +329,9 @@
320329 input: { ...OWNER_REPO, number: { type: "integer" }, title: { type: "string" }, body: { type: "string" }, state: { type: "string", enum: ["open", "closed"] }, labels: { type: "array", items: { type: "string" } } },
321330 required: ["owner", "repo", "number"],
322331 run: async (c, a) => {
323- const { stub, caller } = await repoOf(c, a, "triage");
324- return stub.updateIssue(int(a, "number"), { title: optStr(a, "title"), body: optStr(a, "body"), state: optStr(a, "state") as "open" | undefined, labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller);
332+ const { stub, caller, repo } = await repoOf(c, a, "triage");
333+ const issue = await stub.updateIssue(int(a, "number"), { title: optStr(a, "title"), body: optStr(a, "body"), state: optStr(a, "state") as "open" | undefined, labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller);
334+ return { ...issue, html_url: web(c, repo, `/issues/${int(a, "number")}`) };
325335 },
326336 },
327337 list_workflow_runs: {
@@ -330,8 +340,9 @@
330340 required: ["owner", "repo"],
331341 readOnly: true,
332342 run: async (c, a) => {
333- const { stub } = await repoOf(c, a);
334- return stub.runs({ branch: optStr(a, "branch"), sha: optStr(a, "sha"), status: optStr(a, "status"), prNumber: a.pr_number ? int(a, "pr_number") : undefined, limit: 30 });
343+ const { stub, repo } = await repoOf(c, a);
344+ const res = await stub.runs({ branch: optStr(a, "branch"), sha: optStr(a, "sha"), status: optStr(a, "status"), prNumber: a.pr_number ? int(a, "pr_number") : undefined, limit: 30 });
345+ return { ...res, runs: res.runs.map((r) => ({ ...r, html_url: web(c, repo, `/actions/runs/${r.id}`) })) };
335346 },
336347 },
337348 get_workflow_run: {
@@ -339,7 +350,12 @@
339350 input: { ...OWNER_REPO, run_id: { type: "string" } },
340351 required: ["owner", "repo", "run_id"],
341352 readOnly: true,
342- run: async (c, a) => (await repoOf(c, a)).stub.run(str(a, "run_id")),
353+ run: async (c, a) => {
354+ const { stub, repo } = await repoOf(c, a);
355+ // The RPC result's type is too deep to map over; only the ids are needed here.
356+ const run = (await stub.run(str(a, "run_id"))) as unknown as { id: string; jobs: Array<{ id: string }> };
357+ return { ...run, html_url: web(c, repo, `/actions/runs/${run.id}`), jobs: run.jobs.map((j) => ({ ...j, html_url: web(c, repo, `/actions/runs/${run.id}/job/${j.id}`) })) };
358+ },
343359 },
344360 get_job_logs: {
345361 description: "Fetch a job's log as plain text. Use `tail` to limit to the last N lines (default 400).",
@@ -358,8 +374,9 @@
358374 input: { ...OWNER_REPO, run_id: { type: "string" }, failed_only: { type: "boolean" } },
359375 required: ["owner", "repo", "run_id"],
360376 run: async (c, a) => {
361- const { stub, caller } = await repoOf(c, a, "write");
362- return stub.rerun(str(a, "run_id"), caller, a.failed_only === true ? "failed" : "all");
377+ const { stub, caller, repo } = await repoOf(c, a, "write");
378+ const run = await stub.rerun(str(a, "run_id"), caller, a.failed_only === true ? "failed" : "all");
379+ return { ...run, html_url: web(c, repo, `/actions/runs/${run.id}`) };
363380 },
364381 },
365382 rerun_job: {
@@ -367,8 +384,9 @@
367384 input: { ...OWNER_REPO, job_id: { type: "string" } },
368385 required: ["owner", "repo", "job_id"],
369386 run: async (c, a) => {
370- const { stub, caller } = await repoOf(c, a, "write");
371- return stub.rerunJob(str(a, "job_id"), caller);
387+ const { stub, caller, repo } = await repoOf(c, a, "write");
388+ const run = await stub.rerunJob(str(a, "job_id"), caller);
389+ return { ...run, html_url: web(c, repo, `/actions/runs/${run.id}`) };
372390 },
373391 },
374392 cancel_workflow: {
@@ -387,7 +405,8 @@
387405 required: ["owner", "repo", "workflow"],
388406 run: async (c, a) => {
389407 const { stub, caller, repo } = await repoOf(c, a, "write");
390- return stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller);
408+ const run = await stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller);
409+ return { ...run, html_url: web(c, repo, `/actions/runs/${run.id}`) };
391410 },
392411 },
393412 get_organization: {
modifiedapps/diffinery/test/e2e/e2e.test.mjs+11−0View file
@@ -444,11 +444,22 @@
444444 assert.equal(file.structuredContent.content, "# demo\n");
445445 const commit = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "mcp-change", create_branch_from: "main", message: "Edit via MCP", files: [{ path: "docs/notes.md", content: "hello\n" }] } });
446446 assert.equal(commit.isError, false, JSON.stringify(commit));
447+ assert.equal(commit.structuredContent.html_url, `${base}/${owner}/demo/commit/${commit.structuredContent.sha}`);
447448 const blocked = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "main", message: "sneaky", files: [{ path: "x", content: "y" }] } });
448449 assert.equal(blocked.isError, true);
449450 const opened = await rpc("tools/call", { name: "create_pull_request", arguments: { owner, repo: "demo", title: "Notes", head: "mcp-change", intent: "Add notes" } });
450451 assert.equal(opened.isError, false, JSON.stringify(opened));
451452 assert.equal(opened.structuredContent.number, 2);
453+ // Results link to what they made or read, for the agent to hand people.
454+ assert.equal(opened.structuredContent.html_url, `${base}/${owner}/demo/pull/2`);
455+ const said = await rpc("tools/call", { name: "comment", arguments: { owner, repo: "demo", number: 2, body: "Ready for a look" } });
456+ assert.equal(said.structuredContent.html_url, `${base}/${owner}/demo/issues/2#c-${said.structuredContent.id}`);
457+ const runs = await rpc("tools/call", { name: "list_workflow_runs", arguments: { owner, repo: "demo" } });
458+ const run = runs.structuredContent.runs[0];
459+ assert.equal(run.html_url, `${base}/${owner}/demo/actions/runs/${run.id}`);
460+ const detail = await rpc("tools/call", { name: "get_workflow_run", arguments: { owner, repo: "demo", run_id: run.id } });
461+ assert.equal(detail.structuredContent.jobs[0].html_url, `${base}/${owner}/demo/actions/runs/${run.id}/job/${detail.structuredContent.jobs[0].id}`);
462+ for (const path of [new URL(said.structuredContent.html_url).pathname, new URL(detail.structuredContent.jobs[0].html_url).pathname]) assert.equal((await fetch(`${base}${path}`)).status, 200, path);
452463
453464 // ------------------------------------------------------------ web UI renders the same data (signed in via DEV_AUTH)
454465 for (const path of ["/", `/${owner}`, `/${owner}/demo`, `/${owner}/demo/blob/main/app.txt`, `/${owner}/demo/commits/main`, `/${owner}/demo/pulls`, `/${owner}/demo/pull/1`, `/${owner}/demo/pull/1/files`, `/${owner}/demo/actions`, `/${owner}/demo/actions/runs/${pushRun.id}`, `/${owner}/demo/actions/runs/${pushRun.id}/job/${job1.claim.job_id}`, `/${owner}/demo/settings/branches`, "/settings/tokens"]) {