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

Branches, commits, issues, pull requests, comments and workflow runs
(and their jobs) in MCP results now carry `html_url`, their page on the
web, so an agent can link people to them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ALxdbMU5hpZXjGpL2jhC4
test-bot authored · Claude committed signed1 parent f5f3f60commit 2469998417324137c558d0fc2ccdff9053239d5cBrowse files
3 files changed+59−27
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"]) {