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.

627 lines · 32.3 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import { requireAccount, requireAccountScope, requireScope, type AppContext, type AppVars } from "../auth/principal.js";
4import { HttpError, b64, fromB64 } from "../lib/util.js";
5import type { Role } from "../repo/types.js";
6import { baseRole, type TokenScope } from "../lib/directory.js";
7import { loadRepo } from "../http/common.js";
8import { RELEASE_TOOLS } from "./releases.js";
9import { DOCS_TOOLS } from "./docs.js";
10import { AGENTIC_TOOLS } from "../agentic/mcp.js";
11import { ImportService } from "../imports/service.js";
12import { importJson } from "../http/imports.js";
13
14/**
15 * Model Context Protocol server (Streamable HTTP transport, JSON responses).
16 * Agents connect with a Diffinery token: `Authorization: Bearer dfy_…`.
17 * Every tool runs through the same permission checks, branch protection and
18 * agent guardrails as the REST API and git, so an agent can never do more
19 * than its token, its owner and the repository's rules allow.
20 */
21export const mcp = new Hono<{ Bindings: Env; Variables: AppVars }>();
22
23export const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
24
25type Args = Record<string, unknown>;
26export interface Tool {
27 description: string;
28 input: Record<string, { type: string; description?: string; items?: unknown; enum?: string[] }>;
29 required?: string[];
30 readOnly?: boolean;
31 run: (c: AppContext, a: Args) => Promise<unknown>;
32}
33
34const str = (a: Args, k: string, def?: string): string => {
35 const v = a[k];
36 if (v === undefined || v === null || v === "") {
37 if (def !== undefined) return def;
38 throw new HttpError(422, `${k} is required`);
39 }
40 if (typeof v !== "string") throw new HttpError(422, `${k} must be a string`);
41 return v;
42};
43const optStr = (a: Args, k: string) => (typeof a[k] === "string" && a[k] !== "" ? (a[k] as string) : undefined);
44const int = (a: Args, k: string) => {
45 const n = Number(a[k]);
46 if (!Number.isInteger(n) || n < 1) throw new HttpError(422, `${k} must be a positive integer`);
47 return n;
48};
49// A transferred or renamed repository is served under its old owner/name too.
50const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min, { follow: true });
51const OWNER_REPO = {
52 owner: { type: "string", description: "Repository owner (user or org login)" },
53 repo: { type: "string", description: "Repository name" },
54};
55const decoder = new TextDecoder();
56
57const TOOLS: Record<string, Tool> = {
58 whoami: {
59 description: "Show the account this token acts as, including the human an agent works on behalf of.",
60 input: {},
61 readOnly: true,
62 run: async (c) => {
63 const a = requireAccount(c);
64 const owner = a.owner_id ? await c.get("dir").account(a.owner_id) : null;
65 return { login: a.login, kind: a.kind, on_behalf_of: owner?.login ?? null };
66 },
67 },
68 list_repos: {
69 description: "List repositories visible to you, optionally filtered by owner.",
70 input: { owner: { type: "string" } },
71 readOnly: true,
72 run: async (c, a) => {
73 const me = requireAccount(c);
74 const dir = c.get("dir");
75 const owner = optStr(a, "owner") ? await dir.accountByLogin(optStr(a, "owner")!) : null;
76 const repos = await dir.reposVisibleTo(me, owner?.id);
77 return repos.map((r) => ({ full_name: `${r.owner_login}/${r.name}`, description: r.description, default_branch: r.default_branch, visibility: r.visibility, pushed_at: r.pushed_at }));
78 },
79 },
80 list_tree: {
81 description: "List files and directories at a path in a repository.",
82 input: { ...OWNER_REPO, ref: { type: "string", description: "Branch, tag or commit (default: default branch)" }, path: { type: "string" } },
83 required: ["owner", "repo"],
84 readOnly: true,
85 run: async (c, a) => {
86 const { stub } = await repoOf(c, a);
87 const t = await stub.tree(optStr(a, "ref") ?? "HEAD", optStr(a, "path") ?? "");
88 if (!t) throw new HttpError(404, "path not found");
89 return { sha: t.sha, items: t.items.map((i) => ({ path: i.path, type: i.type })) };
90 },
91 },
92 get_file: {
93 description: "Read a file's contents.",
94 input: { ...OWNER_REPO, path: { type: "string" }, ref: { type: "string" } },
95 required: ["owner", "repo", "path"],
96 readOnly: true,
97 run: async (c, a) => {
98 const { stub } = await repoOf(c, a);
99 const b = await stub.blob(optStr(a, "ref") ?? "HEAD", str(a, "path"));
100 if (!b) throw new HttpError(404, "file not found");
101 if (b.binary) return { path: str(a, "path"), commit: b.sha, size: b.size, binary: true };
102 return { path: str(a, "path"), commit: b.sha, size: b.size, content: decoder.decode(b.content) };
103 },
104 },
105 list_branches: {
106 description: "List branches, marking the default and protected ones.",
107 input: { ...OWNER_REPO },
108 required: ["owner", "repo"],
109 readOnly: true,
110 run: async (c, a) => (await repoOf(c, a)).stub.branches(),
111 },
112 create_branch: {
113 description: "Create a branch from another branch, tag or commit.",
114 input: { ...OWNER_REPO, branch: { type: "string" }, from: { type: "string", description: "Default: the default branch" } },
115 required: ["owner", "repo", "branch"],
116 run: async (c, a) => {
117 const { stub, caller } = await repoOf(c, a, "write");
118 const sha = await stub.resolve(optStr(a, "from") ?? "HEAD");
119 if (!sha) throw new HttpError(404, "source ref not found");
120 await stub.createRef(`refs/heads/${str(a, "branch")}`, sha, caller);
121 return { branch: str(a, "branch"), sha };
122 },
123 },
124 commit_files: {
125 description:
126 "Create one commit that writes and deletes files on a branch, without a git client. Pass expected_head_sha to avoid racing other writers; pass create_branch_from to start a new branch.",
127 input: {
128 ...OWNER_REPO,
129 branch: { type: "string" },
130 message: { type: "string" },
131 files: { type: "array", description: "[{ path, content, encoding?: 'utf-8'|'base64', executable?: boolean }]", items: { type: "object" } },
132 deletes: { type: "array", items: { type: "string" } },
133 expected_head_sha: { type: "string" },
134 create_branch_from: { type: "string" },
135 },
136 required: ["owner", "repo", "branch", "message"],
137 run: async (c, a) => {
138 const { stub, caller } = await repoOf(c, a, "write");
139 const files = Array.isArray(a.files) ? (a.files as Array<{ path: string; content?: string; encoding?: string; executable?: boolean }>) : [];
140 const deletes = Array.isArray(a.deletes) ? (a.deletes as string[]) : [];
141 if (!files.length && !deletes.length) throw new HttpError(422, "nothing to commit");
142 const changes = [
143 ...files.map((f) => ({
144 path: String(f.path),
145 mode: f.executable ? ("100755" as const) : ("100644" as const),
146 content: f.encoding === "base64" ? fromB64(f.content ?? "") : new TextEncoder().encode(f.content ?? ""),
147 })),
148 ...deletes.map((p) => ({ path: String(p), delete: true })),
149 ];
150 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);
151 },
152 },
153 compare: {
154 description: "Compare two refs: commits ahead/behind and a unified diff.",
155 input: { ...OWNER_REPO, base: { type: "string" }, head: { type: "string" } },
156 required: ["owner", "repo", "base", "head"],
157 readOnly: true,
158 run: async (c, a) => (await repoOf(c, a)).stub.compare(str(a, "base"), str(a, "head")),
159 },
160 list_pull_requests: {
161 description: "List pull requests.",
162 input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] } },
163 required: ["owner", "repo"],
164 readOnly: true,
165 run: async (c, a) => {
166 const { stub } = await repoOf(c, a);
167 const { items, total } = await stub.issues({ kind: "pull", state: (optStr(a, "state") as "open") ?? "open", limit: 100 });
168 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 })) };
169 },
170 },
171 get_pull_request: {
172 description: "Get a pull request with its conversation, reviews, mergeability and (optionally) the full diff.",
173 input: { ...OWNER_REPO, number: { type: "integer" }, include_diff: { type: "boolean" } },
174 required: ["owner", "repo", "number"],
175 readOnly: true,
176 run: async (c, a) => {
177 const { stub, caller } = await repoOf(c, a);
178 const n = int(a, "number");
179 const [detail, mergeability] = await Promise.all([stub.issue(n), stub.mergeability(n, caller).catch(() => null)]);
180 const files = a.include_diff ? await stub.pullFiles(n) : undefined;
181 return { ...detail, mergeability, files };
182 },
183 },
184 create_pull_request: {
185 description: "Open a pull request. Use `intent` to explain the goal for reviewers (agents should always set it).",
186 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" } } },
187 required: ["owner", "repo", "title", "head"],
188 run: async (c, a) => {
189 const { stub, caller } = await repoOf(c, a, "write");
190 return stub.createPull(
191 { title: str(a, "title"), body: optStr(a, "body"), head: str(a, "head"), base: optStr(a, "base"), draft: !!a.draft, intent: optStr(a, "intent"), reviewers: Array.isArray(a.reviewers) ? (a.reviewers as string[]) : undefined },
192 caller,
193 );
194 },
195 },
196 comment: {
197 description: "Comment on an issue or pull request.",
198 input: { ...OWNER_REPO, number: { type: "integer" }, body: { type: "string" } },
199 required: ["owner", "repo", "number", "body"],
200 run: async (c, a) => {
201 const { stub, caller } = await repoOf(c, a, "triage");
202 return stub.addComment(int(a, "number"), str(a, "body"), caller);
203 },
204 },
205 review_pull_request: {
206 description: "Submit a review: APPROVE, REQUEST_CHANGES or COMMENT, with optional line comments [{ path, line, side?, body }]. Agent approvals never count toward required human approvals of agent-authored PRs.",
207 input: { ...OWNER_REPO, number: { type: "integer" }, event: { type: "string", enum: ["APPROVE", "REQUEST_CHANGES", "COMMENT"] }, body: { type: "string" }, comments: { type: "array", items: { type: "object" } } },
208 required: ["owner", "repo", "number", "event"],
209 run: async (c, a) => {
210 const { stub, caller } = await repoOf(c, a, "read");
211 return stub.submitReview(int(a, "number"), { event: str(a, "event") as "COMMENT", body: optStr(a, "body"), comments: Array.isArray(a.comments) ? (a.comments as []) : undefined }, caller);
212 },
213 },
214 merge_pull_request: {
215 description:
216 "Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced). When the base branch uses a merge queue, this adds the pull request to the queue instead; it merges once its merge group passes the required checks. Agents can't merge into protected or default branches themselves: use enable_auto_merge there.",
217 input: { ...OWNER_REPO, number: { type: "integer" }, method: { type: "string", enum: ["merge", "squash", "rebase"] }, expected_head_sha: { type: "string" }, delete_branch: { type: "boolean" } },
218 required: ["owner", "repo", "number"],
219 run: async (c, a) => {
220 const { stub, caller } = await repoOf(c, a, "write");
221 const number = int(a, "number");
222 const { issue } = await stub.issue(number);
223 if (issue.pull && (await stub.mergeQueueSettings(issue.pull.base))) {
224 if (optStr(a, "expected_head_sha") && optStr(a, "expected_head_sha") !== issue.pull.headSha) throw new HttpError(409, "head branch was modified; review and try again");
225 return stub.enqueuePull(number, { method: optStr(a, "method") as "merge" | undefined, deleteBranch: !!a.delete_branch }, caller);
226 }
227 return stub.mergePull(number, { method: (optStr(a, "method") as "merge") ?? "merge", expectedHeadSha: optStr(a, "expected_head_sha"), deleteBranch: !!a.delete_branch }, caller);
228 },
229 },
230 enable_auto_merge: {
231 description:
232 "Turn on auto-merge: Diffinery merges the pull request by itself as soon as every branch rule is met (required checks green on the head, approvals from people, linear history), or adds it to the merge queue when the base branch uses one. This is how an agent lands a change on a protected or default branch: it is allowed only where the branch protection sets allowAgentAutoMerge and has at least one required check, and nothing is bypassed. A push to the branch by anyone else turns it off.",
233 input: {
234 ...OWNER_REPO,
235 number: { type: "integer" },
236 merge_method: { type: "string", enum: ["merge", "squash", "rebase"], description: "Default: the merge queue's method, squash where linear history is required, else merge." },
237 commit_title: { type: "string" },
238 commit_message: { type: "string" },
239 },
240 required: ["owner", "repo", "number"],
241 run: async (c, a) => {
242 const { stub, caller } = await repoOf(c, a, "write");
243 return stub.enableAutoMerge(int(a, "number"), { method: optStr(a, "merge_method"), commitTitle: optStr(a, "commit_title"), commitMessage: optStr(a, "commit_message") }, caller);
244 },
245 },
246 disable_auto_merge: {
247 description: "Turn off auto-merge on a pull request.",
248 input: { ...OWNER_REPO, number: { type: "integer" } },
249 required: ["owner", "repo", "number"],
250 run: async (c, a) => {
251 const { stub, caller } = await repoOf(c, a, "write");
252 await stub.disableAutoMerge(int(a, "number"), caller);
253 return { auto_merge: null };
254 },
255 },
256 dequeue_pull_request: {
257 description: "Remove a pull request from its base branch's merge queue.",
258 input: { ...OWNER_REPO, number: { type: "integer" } },
259 required: ["owner", "repo", "number"],
260 run: async (c, a) => {
261 const { stub, caller } = await repoOf(c, a, "write");
262 await stub.dequeuePull(int(a, "number"), caller);
263 return { removed: true };
264 },
265 },
266 list_issues: {
267 description: "List or search issues.",
268 input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] }, q: { type: "string" }, label: { type: "string" } },
269 required: ["owner", "repo"],
270 readOnly: true,
271 run: async (c, a) => {
272 const { stub } = await repoOf(c, a);
273 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 });
274 return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, labels: i.labels })) };
275 },
276 },
277 get_issue: {
278 description: "Get an issue with its comments.",
279 input: { ...OWNER_REPO, number: { type: "integer" } },
280 required: ["owner", "repo", "number"],
281 readOnly: true,
282 run: async (c, a) => (await repoOf(c, a)).stub.issue(int(a, "number")),
283 },
284 create_issue: {
285 description: "Open an issue.",
286 input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, labels: { type: "array", items: { type: "string" } } },
287 required: ["owner", "repo", "title"],
288 run: async (c, a) => {
289 const { stub, caller } = await repoOf(c, a, "read");
290 return stub.createIssue({ title: str(a, "title"), body: optStr(a, "body"), labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller);
291 },
292 },
293 update_issue: {
294 description: "Update an issue or pull request: title, body, state (open/closed), labels.",
295 input: { ...OWNER_REPO, number: { type: "integer" }, title: { type: "string" }, body: { type: "string" }, state: { type: "string", enum: ["open", "closed"] }, labels: { type: "array", items: { type: "string" } } },
296 required: ["owner", "repo", "number"],
297 run: async (c, a) => {
298 const { stub, caller } = await repoOf(c, a, "triage");
299 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);
300 },
301 },
302 list_workflow_runs: {
303 description: "List workflow runs, newest first. Filter by branch, commit sha, PR number or status.",
304 input: { ...OWNER_REPO, branch: { type: "string" }, sha: { type: "string" }, pr_number: { type: "integer" }, status: { type: "string" } },
305 required: ["owner", "repo"],
306 readOnly: true,
307 run: async (c, a) => {
308 const { stub } = await repoOf(c, a);
309 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 });
310 },
311 },
312 get_workflow_run: {
313 description: "Get a run with its jobs, their status, conclusion and failure reason.",
314 input: { ...OWNER_REPO, run_id: { type: "string" } },
315 required: ["owner", "repo", "run_id"],
316 readOnly: true,
317 run: async (c, a) => (await repoOf(c, a)).stub.run(str(a, "run_id")),
318 },
319 get_job_logs: {
320 description: "Fetch a job's log as plain text. Use `tail` to limit to the last N lines (default 400).",
321 input: { ...OWNER_REPO, job_id: { type: "string" }, tail: { type: "integer" } },
322 required: ["owner", "repo", "job_id"],
323 readOnly: true,
324 run: async (c, a) => {
325 const { stub } = await repoOf(c, a);
326 const { lines, complete } = await stub.jobLogs(str(a, "job_id"));
327 const tail = a.tail ? int(a, "tail") : 400;
328 return { complete, truncated: lines.length > tail, log: lines.slice(-tail).map((l) => l.text).join("\n") };
329 },
330 },
331 rerun_workflow: {
332 description: "Re-run a workflow run.",
333 input: { ...OWNER_REPO, run_id: { type: "string" } },
334 required: ["owner", "repo", "run_id"],
335 run: async (c, a) => {
336 const { stub, caller } = await repoOf(c, a, "write");
337 return stub.rerun(str(a, "run_id"), caller);
338 },
339 },
340 cancel_workflow: {
341 description: "Cancel a queued or running workflow run.",
342 input: { ...OWNER_REPO, run_id: { type: "string" } },
343 required: ["owner", "repo", "run_id"],
344 run: async (c, a) => {
345 const { stub, caller } = await repoOf(c, a, "write");
346 await stub.cancelRun(str(a, "run_id"), caller);
347 return { cancelled: true };
348 },
349 },
350 dispatch_workflow: {
351 description: "Start a workflow_dispatch run, e.g. workflow 'deploy.yml' on ref 'main' with inputs.",
352 input: { ...OWNER_REPO, workflow: { type: "string" }, ref: { type: "string" }, inputs: { type: "object" } },
353 required: ["owner", "repo", "workflow"],
354 run: async (c, a) => {
355 const { stub, caller, repo } = await repoOf(c, a, "write");
356 return stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller);
357 },
358 },
359 get_organization: {
360 description: "Show an organization: its name and, to its members and their agents, the base permission every member gets on its repositories (default_repository_permission: none, read, write or admin).",
361 input: { org: { type: "string", description: "Organization login" } },
362 required: ["org"],
363 readOnly: true,
364 run: async (c, a) => {
365 const me = requireAccount(c);
366 const dir = c.get("dir");
367 const org = await dir.accountByLogin(str(a, "org"));
368 if (!org || org.kind !== "org") throw new HttpError(404, "organization not found");
369 return { login: org.login, name: org.name || null, ...((await dir.inOrg(org.id, me)) ? { default_repository_permission: baseRole(org) } : {}) };
370 },
371 },
372 list_invitations: {
373 description: "List pending invitations to a repository (owner and repo, for its admins), an organization (org, for its owners and admins), or the site (neither, for site admins).",
374 input: { ...OWNER_REPO, org: { type: "string", description: "Organization login" } },
375 readOnly: true,
376 run: async (c, a) => {
377 const scope = await inviteScope(c, a, "read");
378 return (await c.get("dir").invites({ ...scope, site: !scope.orgId && !scope.repoId, pending: true })).map((i) => ({ id: i.id, email: i.email, role: i.role, inviter: i.invitedBy, created_at: i.createdAt, expires_at: i.expiresAt }));
379 },
380 },
381 revoke_invitation: {
382 description: "Revoke a pending invitation by id (from list_invitations), so it can't be accepted and the address can be invited again. Takes the same owner and repo, org, or neither as list_invitations.",
383 input: { ...OWNER_REPO, org: { type: "string", description: "Organization login" }, invitation_id: { type: "string" } },
384 required: ["invitation_id"],
385 run: async (c, a) => {
386 const scope = await inviteScope(c, a, "admin");
387 const dir = c.get("dir");
388 const id = str(a, "invitation_id");
389 if (!(await dir.revokeInvite(id, scope))) throw new HttpError(404, "no pending invitation with this id");
390 const me = requireAccount(c);
391 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: scope.repoId ? "repo.invite.revoke" : scope.orgId ? "org.invite.revoke" : "invite.revoke", target: optStr(a, "org") ?? (scope.repoId ? `${optStr(a, "owner")}/${optStr(a, "repo")}` : undefined), detail: { id } }));
392 return { revoked: true };
393 },
394 },
395 sandbox_create: {
396 description: "Start an isolated Linux sandbox with the repository checked out (git push from inside it uses your permissions). Returns a sandbox id.",
397 input: { ...OWNER_REPO, ref: { type: "string" } },
398 required: ["owner", "repo"],
399 run: async (c, a) => {
400 const { stub, caller } = await repoOf(c, a, "write");
401 return stub.createSandbox(caller, optStr(a, "ref"));
402 },
403 },
404 sandbox_exec: {
405 description: "Run a shell command in a sandbox. Returns exit code, stdout and stderr.",
406 input: { ...OWNER_REPO, sandbox_id: { type: "string" }, command: { type: "string" }, cwd: { type: "string" }, timeout_seconds: { type: "integer" } },
407 required: ["owner", "repo", "sandbox_id", "command"],
408 run: (c, a) => sandboxCall(c, a, "/exec", { cmd: str(a, "command"), cwd: optStr(a, "cwd"), timeoutMs: a.timeout_seconds ? int(a, "timeout_seconds") * 1000 : undefined }),
409 },
410 sandbox_read_file: {
411 description: "Read a file inside a sandbox.",
412 input: { ...OWNER_REPO, sandbox_id: { type: "string" }, path: { type: "string" } },
413 required: ["owner", "repo", "sandbox_id", "path"],
414 run: async (c, a) => {
415 const r = (await sandboxCall(c, a, "/files/read", { path: str(a, "path") })) as { path: string; size: number; content: string };
416 const bytes = fromB64(r.content);
417 const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
418 try {
419 return { path: r.path, size: r.size, content: text.decode(bytes) };
420 } catch {
421 return { path: r.path, size: r.size, encoding: "base64", content: r.content };
422 }
423 },
424 },
425 sandbox_write_file: {
426 description: "Write a file inside a sandbox.",
427 input: { ...OWNER_REPO, sandbox_id: { type: "string" }, path: { type: "string" }, content: { type: "string" } },
428 required: ["owner", "repo", "sandbox_id", "path", "content"],
429 run: (c, a) => sandboxCall(c, a, "/files/write", { path: str(a, "path"), content: b64(new TextEncoder().encode(str(a, "content", ""))) }),
430 },
431 sandbox_close: {
432 description: "Shut down a sandbox.",
433 input: { ...OWNER_REPO, sandbox_id: { type: "string" } },
434 required: ["owner", "repo", "sandbox_id"],
435 run: async (c, a) => {
436 const { stub, caller } = await repoOf(c, a, "write");
437 await stub.closeSandbox(str(a, "sandbox_id"), caller);
438 return { closed: true };
439 },
440 },
441 ...RELEASE_TOOLS,
442 ...DOCS_TOOLS,
443 ...AGENTIC_TOOLS,
444 start_github_import: {
445 description:
446 "Start a server-side import of a GitHub repository into Diffinery: git history (keeping issue and pull-request numbers), issues and pull requests with comments and reviews, labels, milestones, branch protection, Actions variables, releases and deployment environments. Secret VALUES are never read from GitHub (only their names are reported, to set by hand), and push protection is never bypassed on an agent's behalf. An agent can import only where its owner may: the owner's own account or an organization the owner administers (an organization's agent: that organization), and only when the agent's policy allows write. Returns the import id; poll it with get_import.",
447 input: {
448 source: { type: "string", description: "GitHub repository as owner/name or a github.com/Enterprise URL" },
449 owner: { type: "string", description: "Diffinery user or organization to import into" },
450 name: { type: "string", description: "Target repository name (default: the GitHub name)" },
451 visibility: { type: "string", enum: ["keep", "private", "public"], description: "Visibility of the new repository (default: keep GitHub's; GitHub internal repositories become private)" },
452 github_token: { type: "string", description: "A GitHub token to read a private repository (optional)" },
453 github_api_url: { type: "string", description: "GitHub Enterprise REST API base, e.g. https://ghe.example.com/api/v3 (optional)" },
454 },
455 required: ["source", "owner"],
456 run: async (c, a) => {
457 const me = requireAccount(c);
458 requireAccountScope(c, "write");
459 const svc = new ImportService(c.env);
460 const row = await svc.create(me, importScopes(c), {
461 source: str(a, "source"),
462 owner: str(a, "owner"),
463 name: optStr(a, "name"),
464 visibility: optStr(a, "visibility"),
465 github_token: optStr(a, "github_token"),
466 github_api_url: optStr(a, "github_api_url"),
467 });
468 return importJson(c.env, svc, row, { children: true });
469 },
470 },
471 get_import: {
472 description: "Get an import you started: its status, per-step progress, warnings, any error, and (for an organization import) its repositories.",
473 input: { id: { type: "string", description: "The import id" } },
474 required: ["id"],
475 readOnly: true,
476 run: async (c, a) => {
477 const me = requireAccount(c);
478 const svc = new ImportService(c.env);
479 const row = await svc.row(str(a, "id"));
480 if (!row || !(await svc.visibleTo(row, me))) throw new HttpError(404, "import not found");
481 return importJson(c.env, svc, row, { children: true });
482 },
483 },
484 list_imports: {
485 description: "List the imports you have started, newest first.",
486 input: {},
487 readOnly: true,
488 run: async (c) => {
489 const me = requireAccount(c);
490 const svc = new ImportService(c.env);
491 const rows = await svc.list(me.id);
492 return Promise.all(rows.map((r) => importJson(c.env, svc, r)));
493 },
494 },
495};
496
497/** The token's scopes, used to authorize starting an import (needs write or admin). */
498function importScopes(c: AppContext): TokenScope[] {
499 const p = c.get("principal");
500 if (p.type === "account" && p.importId) throw new HttpError(403, "this token can only be used by its GitHub import");
501 return p.type === "account" ? p.scopes : [];
502}
503
504/** Which invitations a caller may manage: a repository's (admins), an organization's (owners and admins) or the site's (site admins). */
505async function inviteScope(c: AppContext, a: Args, need: "read" | "admin"): Promise<{ orgId?: string; repoId?: string }> {
506 if (optStr(a, "owner") || optStr(a, "repo")) return { repoId: (await repoOf(c, a, "admin")).repo.id };
507 // Organization and site invitations are account settings, as in the REST API.
508 requireAccountScope(c, need);
509 const me = requireAccount(c);
510 const dir = c.get("dir");
511 if (optStr(a, "org")) {
512 const org = await dir.accountByLogin(str(a, "org"));
513 if (!org || org.kind !== "org") throw new HttpError(404, "organization not found");
514 const role = me.kind === "agent" ? (me.owner_id ? await dir.orgRole(org.id, me.owner_id) : null) : await dir.orgRole(org.id, me.id);
515 if (role !== "owner" && role !== "admin" && !me.site_admin) throw new HttpError(403, "only organization owners and admins can manage its invitations");
516 return { orgId: org.id };
517 }
518 if (!me.site_admin) throw new HttpError(403, "only site admins can manage invitations to the site; pass owner and repo, or org");
519 return {};
520}
521
522async function sandboxCall(c: AppContext, a: Args, path: string, payload: unknown) {
523 const { stub, caller } = await repoOf(c, a, "write");
524 const id = str(a, "sandbox_id");
525 await stub.sandbox(id, caller);
526 const res = await c.env.SANDBOX.get(c.env.SANDBOX.idFromName(id)).call(path, JSON.stringify(payload));
527 let out: unknown = res.body;
528 try {
529 out = JSON.parse(res.body);
530 } catch {
531 /* plain text */
532 }
533 if (res.status >= 400) throw new HttpError(res.status, out && typeof out === "object" && "error" in out ? String((out as { error: unknown }).error) : res.body || "sandbox error");
534 return out;
535}
536
537/** The tools/list entries. The public docs are generated from this list too. */
538export function mcpTools() {
539 return Object.entries(TOOLS).map(([name, t]) => ({
540 name,
541 description: t.description,
542 inputSchema: { type: "object" as const, properties: t.input, required: t.required ?? [] },
543 annotations: { readOnlyHint: !!t.readOnly },
544 }));
545}
546
547interface RpcRequest {
548 jsonrpc: "2.0";
549 id?: string | number | null;
550 method: string;
551 params?: Record<string, unknown>;
552}
553
554const rpcError = (id: RpcRequest["id"], code: number, message: string) => ({ jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } });
555
556async function handle(c: AppContext, req: RpcRequest): Promise<unknown | null> {
557 const isNotification = req.id === undefined;
558 switch (req.method) {
559 case "initialize": {
560 const asked = String(req.params?.protocolVersion ?? "");
561 return {
562 jsonrpc: "2.0",
563 id: req.id,
564 result: {
565 protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
566 capabilities: { tools: { listChanged: false } },
567 serverInfo: { name: "diffinery", title: "Diffinery", version: "0.1.0" },
568 instructions:
569 "Diffinery is a git forge built for agents. Read code with get_file/list_tree, change it with commit_files on a branch, open a pull request with an `intent`, watch CI with list_workflow_runs/get_job_logs, and use sandboxes to build and test. Look things up in the docs with search_docs/read_docs. Protected branches and required human reviews are enforced server-side.",
570 },
571 };
572 }
573 case "notifications/initialized":
574 case "notifications/cancelled":
575 return null;
576 case "ping":
577 return { jsonrpc: "2.0", id: req.id, result: {} };
578 case "tools/list":
579 return {
580 jsonrpc: "2.0",
581 id: req.id,
582 result: { tools: mcpTools() },
583 };
584 case "tools/call": {
585 const name = String(req.params?.name ?? "");
586 const tool = TOOLS[name];
587 if (!tool) return rpcError(req.id, -32602, `unknown tool: ${name}`);
588 try {
589 c.set("readOnly", !!tool.readOnly);
590 if (!tool.readOnly) requireScope(c, "write");
591 const out = await tool.run(c, (req.params?.arguments as Args) ?? {});
592 return { jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out && typeof out === "object" && !Array.isArray(out) ? out : { result: out }, isError: false } };
593 } catch (e) {
594 const msg = e instanceof HttpError ? e.detail : String((e as Error)?.message ?? e).replace(/^\d{3}: /, "");
595 return { jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: msg }], isError: true } };
596 }
597 }
598 default:
599 return isNotification ? null : rpcError(req.id, -32601, `method not found: ${req.method}`);
600 }
601}
602
603mcp.get("/mcp", (c) => c.json({ message: "Diffinery MCP: POST JSON-RPC here with Authorization: Bearer <token>" }, 405, { allow: "POST" }));
604mcp.delete("/mcp", (c) => c.body(null, 405));
605mcp.post("/mcp", async (c) => {
606 // Every MCP call needs an active account (a token for an agent or a person).
607 requireAccount(c);
608 let payload: unknown;
609 try {
610 payload = await c.req.json();
611 } catch {
612 return c.json(rpcError(null, -32700, "parse error"), 400);
613 }
614 const batch = Array.isArray(payload);
615 const reqs = (batch ? payload : [payload]) as RpcRequest[];
616 const out: unknown[] = [];
617 for (const r of reqs) {
618 if (!r || typeof r !== "object" || typeof r.method !== "string") {
619 out.push(rpcError(null, -32600, "invalid request"));
620 continue;
621 }
622 const res = await handle(c, r);
623 if (res) out.push(res);
624 }
625 if (!out.length) return c.body(null, 202);
626 return c.json(batch ? out : out[0]);
627});