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.

448 lines · 25.4 KBRaw History
1/**
2 * Reference material generated from the running code: the REST API (from the
3 * router itself, plus the notes in ./api-meta.ts) and the MCP tools (from the
4 * MCP server's tool list). Nothing here is written by hand per endpoint or per
5 * tool, so the docs can't fall behind the code.
6 */
7import { app } from "../app.js";
8import { mcpTools, PROTOCOL_VERSIONS } from "../mcp/server.js";
9import { ACCOUNT_SCOPES, API_DOCS, type Access, type Field, type OpDoc, type Scope } from "./api-meta.js";
10
11export const API_BASE = "/api/v3";
12
13export interface ApiOperation {
14 method: string;
15 /** OpenAPI-style path relative to /api/v3, like /repos/{owner}/{repo}. */
16 path: string;
17 params: string[];
18 tag: string;
19 doc: OpDoc;
20 /** False when the summary was derived from the method and path. */
21 documented: boolean;
22 /** The least token scope that may call it, and whether the token must cover every repository. */
23 scope: Scope;
24 accountWide: boolean;
25 /** No token may call it: only a browser session. */
26 sessionOnly: boolean;
27}
28
29const METHOD_ORDER = ["GET", "POST", "PUT", "PATCH", "DELETE"];
30
31/** Hono path → OpenAPI path: `:name{regex}` and `:name` → `{name}`, a trailing `*` → `{path}`. */
32export function openApiPath(honoPath: string): string {
33 return honoPath.replace(/:(\w+)(\{[^}]*\})?/g, "{$1}").replace(/\/\*$/, "/{path}");
34}
35
36/** Groups, in the order the reference lists them. The first matching rule wins. */
37const TAGS: Array<[string, RegExp]> = [
38 ["Meta", /^\/(meta|rate_limit)?$/],
39 ["Users", /^\/users?(\/\{login\})?$|^\/user\/(orgs|avatar)$/],
40 ["Organizations", /^\/orgs\/\{org\}(\/(members|invitations(\/\{id\})?|avatar))?$|^\/invitations(\/\{id\})?$/],
41 ["Search", /^\/search\//],
42 ["Repositories", /^\/(user|users\/\{login\}|orgs\/\{org\})\/repos$|^\/repos\/\{owner\}\/\{repo\}(\/(collaborators|invitations|assignees|languages|topics|dispatches)(\/|$)|$)/],
43 ["Contents", /\/(contents|raw|readme|tarball|zipball|diffinery\/commits)(\/|$)/],
44 ["Releases", /\/releases(\/|$)/],
45 ["Git database", /\/git\/(blobs|trees|commits|tags|refs|ref|matching-refs)(\/|$)/],
46 ["Branches and tags", /\/(branches|tags|merges)(\/|$)(?!.*protection)/],
47 ["Commit statuses", /\/statuses\/|\/commits\/\{ref\}\/status(es)?$/],
48 ["Checks", /\/check-(runs|suites)/],
49 ["Commits", /\/(commits|compare)(\/|$)/],
50 ["Branch protection", /\/protection$/],
51 ["Issues", /\/(issues|labels|milestones)(\/|$)/],
52 ["Pull requests", /\/pulls(\/|$)(?!.*merge-queue)/],
53 ["Merge queue", /\/merge-queue/],
54 ["Deployments", /\/deployments(\/|$)/],
55 ["Environments", /\/environments(\/|$)/],
56 ["Actions secrets and variables", /\/actions\/(secrets|variables)/],
57 ["Actions artifacts and caches", /\/actions\/(artifacts|caches?)(\/|$)|\/actions\/runs\/\{runId\}\/artifacts$/],
58 ["Actions", /\/actions\//],
59 ["Webhooks", /\/hooks(\/|$)/],
60 ["Packages", /\/packages(\/|$)/],
61 ["Imports", /^\/diffinery\/(imports|user\/linked-accounts|admin\/linked-accounts)(\/|$)/],
62 ["Status page", /^\/diffinery\/(admin\/)?status(\/|$)/],
63 ["Notifications and email", /^\/notifications$|\/subscription$|\/diffinery\/(notification-settings|emails|admin)/],
64 ["Tokens and agents", /\/diffinery\/(tokens|agents)/],
65 ["Sandboxes", /\/diffinery\/sandboxes/],
66];
67export const TAG_ORDER = [...TAGS.map(([t]) => t), "Other"];
68
69function tagFor(path: string): string {
70 for (const [tag, re] of TAGS) if (re.test(path)) return tag;
71 return "Other";
72}
73
74const words = (s: string) => s.replace(/[-_]/g, " ");
75const singular = (s: string) => s.replace(/ies$/, "y").replace(/(ss)$/, "$1").replace(/s$/, "");
76
77/** A readable summary for an endpoint nobody has written notes for yet. */
78export function derivedSummary(method: string, path: string): string {
79 const segs = path.split("/").filter(Boolean);
80 const last = segs[segs.length - 1] ?? "";
81 const endsWithParam = last.startsWith("{");
82 const noun = words([...segs].reverse().find((s) => !s.startsWith("{")) ?? "resource");
83 const one = `a ${singular(noun)}`;
84 switch (method) {
85 case "GET":
86 return endsWithParam ? `Get ${one}` : `List ${noun}`;
87 case "POST":
88 return endsWithParam ? `Run ${words(last.slice(1, -1))} on ${one}` : `Create ${one}`;
89 case "PUT":
90 return endsWithParam ? `Set ${one}` : `Set ${noun}`;
91 case "PATCH":
92 return `Update ${endsWithParam ? one : noun}`;
93 case "DELETE":
94 return `Delete ${endsWithParam ? one : noun}`;
95 default:
96 return `${method} ${path}`;
97 }
98}
99
100let opsCache: ApiOperation[] | null = null;
101
102/** Every REST endpoint the Worker serves under /api/v3. */
103export function apiOperations(): ApiOperation[] {
104 if (opsCache) return opsCache;
105 const seen = new Set<string>();
106 const ops: ApiOperation[] = [];
107 for (const r of app.routes) {
108 if (!METHOD_ORDER.includes(r.method) || !(r.path === API_BASE || r.path.startsWith(`${API_BASE}/`))) continue;
109 const path = openApiPath(r.path.slice(API_BASE.length)) || "/";
110 const key = `${r.method} ${path}`;
111 if (seen.has(key)) continue;
112 seen.add(key);
113 const doc = API_DOCS[key];
114 ops.push({
115 method: r.method,
116 path,
117 params: [...path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!),
118 tag: tagFor(path),
119 doc: doc ?? { summary: derivedSummary(r.method, path) },
120 documented: !!doc,
121 // A token's scope also caps its repository role (write → maintain), so admin endpoints need an admin token.
122 scope: ACCOUNT_SCOPES[key]?.scope ?? (doc?.access === "admin" ? "admin" : r.method === "GET" ? "read" : "write"),
123 accountWide: !!ACCOUNT_SCOPES[key] && !ACCOUNT_SCOPES[key]!.anyToken,
124 sessionOnly: !!ACCOUNT_SCOPES[key]?.sessionOnly,
125 });
126 }
127 ops.sort((a, b) => TAG_ORDER.indexOf(a.tag) - TAG_ORDER.indexOf(b.tag) || a.path.localeCompare(b.path) || METHOD_ORDER.indexOf(a.method) - METHOD_ORDER.indexOf(b.method));
128 // Only cache once the web UI is mounted too (index.ts mounts it after app.ts loads).
129 if (ops.length) opsCache = ops;
130 return ops;
131}
132
133export const ACCESS_TEXT: Record<Access, string> = {
134 public: "No authentication needed.",
135 account: "Any active account (a token, or a browser session).",
136 read: "Read access to the repository. Public repositories allow anyone to read.",
137 triage: "Triage access to the repository.",
138 write: "Write access to the repository.",
139 maintain: "Maintain access to the repository.",
140 admin: "Admin access to the repository (or to the package).",
141 "org-admin": "An owner or admin of the organization (or a site admin).",
142 "org-owner": "An owner of the organization (or a site admin).",
143 "site-admin": "A site administrator.",
144};
145
146/** What a personal access token needs; browser sessions always have full scope. */
147export function scopeText(op: Pick<ApiOperation, "scope" | "accountWide" | "sessionOnly" | "doc">): string {
148 if (op.doc.access === "public") return "";
149 if (op.sessionOnly) return "No token can call it: it needs a browser session.";
150 return `Tokens need the \`${op.scope}\` scope${op.accountWide ? " and access to all repositories" : ""}.`;
151}
152
153const PARAM_TEXT: Record<string, string> = {
154 owner: "Account (user or organization) that owns the repository or package.",
155 repo: "Repository name.",
156 org: "Organization login.",
157 login: "Account login.",
158 number: "Issue or pull request number.",
159 branch: "Branch name (for protection rules: a branch name or pattern).",
160 ref: "Branch, tag or commit SHA.",
161 path: "Path inside the repository, or a ref name after `refs/`.",
162 spec: "Two refs as `base...head`.",
163 id: "Identifier.",
164 name: "Name.",
165 type: "Package type: `container`.",
166 workflow: "Workflow file path (URL-encoded) or file name.",
167 runId: "Workflow run id, or the run number (`github.run_id`).",
168 format: "Archive format: `zip`.",
169 action: "Action to take.",
170};
171
172const EXAMPLE: Record<string, string> = { owner: "acme", repo: "website", org: "acme", login: "diffy", number: "42", branch: "main", ref: "main", path: "README.md", spec: "main...feature", type: "container", name: "website", workflow: "ci.yml", action: "exec", runId: "42", format: "zip" };
173
174function curl(origin: string, op: ApiOperation): string {
175 const url = `${origin}${API_BASE}${op.path === "/" ? "" : op.path.replace(/\{(\w+)\}/g, (_, p: string) => EXAMPLE[p] ?? (p === "id" ? "1" : p))}`;
176 const lines = [`curl${op.method === "GET" ? "" : ` -X ${op.method}`} "${url}"`];
177 if (op.doc.access !== "public") lines.push(` -H "Authorization: Bearer $DIFFINERY_TOKEN"`);
178 const required = Object.entries(op.doc.body ?? {}).filter(([, f]) => f[2]);
179 if (required.length) {
180 const sample = Object.fromEntries(required.map(([k, f]) => [k, f[0] === "string[]" ? ["..."] : f[0] === "array" ? [] : f[0] === "object" ? {} : f[0] === "integer" ? 1 : f[0] === "boolean" ? true : "..."]));
181 lines.push(` -H "Content-Type: application/json"`, ` -d '${JSON.stringify(sample)}'`);
182 }
183 return lines.join(" \\\n");
184}
185
186const cell = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
187
188function fieldTable(title: string, fields: Record<string, Field>): string[] {
189 return [`${title}:`, "", "| Name | Type | Required | Description |", "| --- | --- | --- | --- |", ...Object.entries(fields).map(([k, [t, d, req]]) => `| \`${k}\` | ${t} | ${req ? "yes" : "no"} | ${cell(d)} |`), ""];
190}
191
192/** The /docs/api page: every endpoint, grouped, with parameters and an example. */
193export function apiReferenceMarkdown(origin: string): string {
194 const ops = apiOperations();
195 const byTag = new Map<string, ApiOperation[]>();
196 for (const op of ops) byTag.set(op.tag, [...(byTag.get(op.tag) ?? []), op]);
197 const used = new Set<string>();
198 const heading = (op: ApiOperation) => {
199 let h = op.doc.summary;
200 if (used.has(h.toLowerCase())) h = `${h} (${op.method} ${op.path.replace(/[{}]/g, "")})`;
201 used.add(h.toLowerCase());
202 return h;
203 };
204 const out: string[] = [
205 `Every endpoint of the Diffinery REST API, generated from the server's router, so this list always matches what \`${origin}${API_BASE}\` serves. The same information is available as an [OpenAPI 3.1 document](/docs/api/openapi.json). For authentication, errors, pagination and GitHub compatibility, read the [REST API guide](/docs/reference/rest-api).`,
206 "",
207 "## Basics",
208 "",
209 `- Base URL: \`${origin}${API_BASE}\``,
210 "- Authenticate with a personal access token: `Authorization: Bearer dfy_...` (`token dfy_...` and HTTP Basic with the token as the password work too). Create one under *Settings → Access tokens*.",
211 "- Send and receive JSON. Errors are JSON objects with a `message` and a `documentation_url`, unknown paths included (404).",
212 "- Reads of public repositories need no token. Every write needs a signed-in caller (401 otherwise) and, from a token, the `write` scope (403 otherwise). A token's scope also caps its role on a repository: `read` reads, `write` acts up to maintain, `admin` acts as admin. Each endpoint below says which scope it needs.",
213 "- A private repository you can't see is a 404, whatever the method, so a read token's write to it doesn't reveal that it exists.",
214 "- An archived repository refuses every write with 403 `repository is archived (read-only)`, except unarchiving it (`PATCH` with `archived: false`), deleting it and watching it.",
215 "- A workflow job's `GITHUB_TOKEN` follows the job's `permissions:` instead of token scopes: each endpoint needs its permission (`contents`, `issues`, `pull-requests`, `actions` and so on), and settings, secrets, variables, webhooks and branch protection are out of its reach. See [Workflow permissions](/docs/actions/workflow-permissions).",
216 "- Paths, field names and status codes follow GitHub's REST API wherever Diffinery has the same feature, so most GitHub clients work with `baseUrl` set to the URL above.",
217 `- ${ops.length} endpoints in ${byTag.size} groups.`,
218 "",
219 "## Endpoints by group",
220 "",
221 ...[...byTag.entries()].map(([tag, list]) => `- [${tag}](#${slug(tag)}) (${list.length})`),
222 "",
223 ];
224 for (const [tag, list] of byTag) {
225 out.push(`## ${tag}`, "", "| Method | Path | Summary |", "| --- | --- | --- |");
226 const heads = list.map((op) => heading(op));
227 list.forEach((op, i) => out.push(`| ${op.method} | \`${op.path}\` | [${cell(op.doc.summary)}](#${slug(heads[i]!)}) |`));
228 out.push("");
229 list.forEach((op, i) => {
230 out.push(`### ${heads[i]}`, "", "```http", `${op.method} ${API_BASE}${op.path === "/" ? "" : op.path}`, "```", "");
231 if (op.doc.description) out.push(op.doc.description, "");
232 out.push(`**Access:** ${[ACCESS_TEXT[op.doc.access ?? "read"], scopeText(op)].filter(Boolean).join(" ")}${op.doc.status ? ` **Success:** \`${op.doc.status}\`.` : ""}`, "");
233 if (op.params.length) out.push(...fieldTable("Path parameters", Object.fromEntries(op.params.map((p) => [p, ["string", PARAM_TEXT[p] ?? p, true] as Field]))));
234 if (op.doc.query) out.push(...fieldTable("Query parameters", op.doc.query));
235 if (op.doc.body) out.push(...fieldTable("Body (JSON)", op.doc.body));
236 out.push("```sh", curl(origin, op), "```", "");
237 });
238 }
239 out.push(
240 "## Other HTTP endpoints",
241 "",
242 "These aren't part of the JSON API, but clients and tools talk to them directly.",
243 "",
244 "| Endpoint | What it is |",
245 "| --- | --- |",
246 ...OTHER_ENDPOINTS.map(([path, what]) => `| \`${path}\` | ${what} |`),
247 "",
248 );
249 return out.join("\n");
250}
251
252/** Non-API endpoints worth knowing. A unit test checks that each one is still routed. */
253export const OTHER_ENDPOINTS: Array<[string, string]> = [
254 ["GET /{owner}/{repo}.git/info/refs", "Git smart HTTP: ref advertisement (`git clone`, `git fetch`, `git push`). Authenticate with a token as the password. See [Git](/docs/repositories/git)."],
255 ["POST /{owner}/{repo}.git/git-upload-pack", "Git smart HTTP: fetch and clone."],
256 ["POST /{owner}/{repo}.git/git-receive-pack", "Git smart HTTP: push."],
257 ["POST /{owner}/{repo}.git/info/lfs/objects/batch", "Git LFS batch API. See [Git LFS](/docs/repositories/git-lfs)."],
258 ["GET, PUT /{owner}/{repo}.git/info/lfs/objects/{oid}", "Git LFS object download and upload."],
259 ["GET /{owner}/{repo}/actions/runs/{run_id}/artifacts/{name}", "Download a workflow artifact by name or numeric id, for browsers (the run page links here). Serves a zip, the file itself for an artifact uploaded as a single unzipped file, or a `.tar.gz` for an artifact from an older runner. Expired artifacts aren't served."],
260 ["GET /{owner}/{repo}/agent-setup.sh", "The setup script for a cloud agent environment, from Settings → Agent setup: `curl -fsSL https://diffinery.com/{owner}/{repo}/agent-setup.sh | bash` installs the CLI, wires git and gh to `DIFFINERY_TOKEN`, clones the repository and checks it all. The same script for anyone, with no secret in it. See [Cloud agent environments](/docs/connect/cloud-agents)."],
261 ["POST /mcp", "The MCP server (Streamable HTTP, JSON-RPC). See [MCP](/docs/agents/mcp) and the [MCP tool reference](/docs/reference/mcp-tools)."],
262 ["GET /.well-known/oauth-protected-resource/mcp", "OAuth protected resource metadata for the MCP server (RFC 9728); also at `/.well-known/oauth-protected-resource`. See [How sign-in works](/docs/connect/how-sign-in-works)."],
263 ["GET /.well-known/oauth-authorization-server", "OAuth authorization server metadata (RFC 8414) for MCP clients' browser sign-in."],
264 ["POST /oauth/register", "Dynamic client registration for MCP clients (RFC 7591): public clients, or confidential ones with `client_secret_basic` or `client_secret_post`."],
265 ["GET, POST /oauth/authorize", "The consent page where a person connects an MCP client, choosing whom it acts as and its access. Authorization code with PKCE (S256) only."],
266 ["POST /oauth/token", "Trades an authorization code (with its PKCE verifier) or a refresh token for an 8-hour access token and a new refresh token."],
267 ["POST /oauth/revoke", "Revokes a connected app's access or refresh token, ending its connection (RFC 7009)."],
268 ["/v2/...", "The OCI container registry (Docker Registry HTTP API v2). See [Container registry](/docs/packages/container-registry)."],
269 ["GET /.well-known/openid-configuration", "OIDC discovery for workflow ID tokens. See [OIDC](/docs/actions/oidc)."],
270 ["GET /.well-known/jwks", "Public keys that sign workflow ID tokens."],
271 ["GET /.well-known/security.txt", "Where to report a vulnerability (RFC 9116): this instance's report form first, then any `SECURITY_CONTACT` entries. See [Reporting a vulnerability](/docs/security/disclosure)."],
272 ["GET /.well-known/mcp-registry-auth", "The proof record that lets this instance's MCP server be published to the official MCP registry under its domain; 404 until `MCP_REGISTRY_AUTH` is set. See [MCP](/docs/agents/mcp#list-your-instance-in-the-mcp-registry)."],
273 ["GET, POST /security/report", "The private security report form: signed in, or anonymously after Cloudflare Turnstile. Only site administrators can read reports. See [Reporting a vulnerability](/docs/security/disclosure)."],
274 ["POST /-/email/events", "Signed bounce and complaint intake for the email suppression list; 404 until `MAIL_EVENTS_SECRET` is set. See [Site administration](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list)."],
275 ["GET, POST /-/unsubscribe/{token}", "One-click unsubscribe from a notification email (RFC 8058): a POST with `List-Unsubscribe=One-Click` turns that kind of email off. See [Notifications](/docs/accounts/notifications)."],
276 ["GET /status", "The status page: each component's status and the incidents site administrators posted, for anyone without signing in. As JSON at `GET /api/v3/diffinery/status`. See [Status page](/docs/self-hosting/status-page)."],
277 ["GET /healthz", "Health check: `{\"ok\": true}`. Point an external uptime monitor at it: the status page can't report the whole Worker being down."],
278 ["GET /llms.txt", "Index of these docs for AI agents. See [Docs for AI agents](/docs/agents/docs-for-agents)."],
279 ["GET /llms-full.txt", "All of these docs in one Markdown file."],
280 ["GET /docs/{page}.md", "Any docs page as Markdown."],
281 ["GET /docs/api/openapi.json", "This reference as an OpenAPI 3.1 document."],
282];
283
284// Same rules as slugify() in src/web/markdown.ts.
285function slug(text: string): string {
286 return text
287 .toLowerCase()
288 .replace(/[^\p{L}\p{N}\s_-]/gu, "")
289 .trim()
290 .replace(/\s+/g, "-")
291 .slice(0, 80);
292}
293
294// ---------------------------------------------------------------- OpenAPI
295
296function schemaFor(type: string): Record<string, unknown> {
297 if (type.endsWith("[]")) return { type: "array", items: schemaFor(type.slice(0, -2)) };
298 if (["string", "integer", "boolean", "object", "array", "number"].includes(type)) return { type };
299 return {};
300}
301
302/** The REST API as an OpenAPI 3.1 document. */
303export function openApiDocument(origin: string, version: string): Record<string, unknown> {
304 const paths: Record<string, Record<string, unknown>> = {};
305 for (const op of apiOperations()) {
306 const params = [
307 ...op.params.map((p) => ({ name: p, in: "path", required: true, description: PARAM_TEXT[p] ?? p, schema: { type: "string" } })),
308 ...Object.entries(op.doc.query ?? {}).map(([name, [t, d, req]]) => ({ name, in: "query", required: !!req, description: d, schema: schemaFor(t) })),
309 ];
310 const body = op.doc.body
311 ? {
312 required: Object.values(op.doc.body).some((f) => f[2]),
313 content: {
314 "application/json": {
315 schema: {
316 type: "object",
317 properties: Object.fromEntries(Object.entries(op.doc.body).map(([k, [t, d]]) => [k, { ...schemaFor(t), description: d }])),
318 required: Object.entries(op.doc.body)
319 .filter(([, f]) => f[2])
320 .map(([k]) => k),
321 },
322 },
323 },
324 }
325 : undefined;
326 const ok = String(op.doc.status ?? 200);
327 const access = op.doc.access ?? "read";
328 (paths[op.path] ??= {})[op.method.toLowerCase()] = {
329 operationId: operationId(op),
330 summary: op.doc.summary,
331 description: [op.doc.description, `Access: ${[ACCESS_TEXT[access], scopeText(op)].filter(Boolean).join(" ")}`].filter(Boolean).join("\n\n"),
332 tags: [op.tag],
333 ...(params.length ? { parameters: params } : {}),
334 ...(body ? { requestBody: body } : {}),
335 ...(access === "public" ? { security: [] } : {}),
336 "x-diffinery-access": access,
337 "x-diffinery-token-scope": op.sessionOnly ? "none" : op.scope,
338 responses: {
339 [ok]: { description: ok === "204" ? "No content" : "Success", ...(ok === "204" || ok === "205" ? {} : { content: { "application/json": { schema: {} } } }) },
340 default: { description: "Error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
341 },
342 };
343 }
344 return {
345 openapi: "3.1.0",
346 info: {
347 title: "Diffinery REST API",
348 version,
349 description: `The Diffinery REST API, generated from the server's router. Paths and payloads follow GitHub's REST API v3 where Diffinery has the same feature. Guide: ${origin}/docs/reference/rest-api`,
350 },
351 externalDocs: { description: "Diffinery docs", url: `${origin}/docs` },
352 servers: [{ url: `${origin}${API_BASE}` }],
353 security: [{ token: [] }],
354 tags: TAG_ORDER.filter((t) => apiOperations().some((o) => o.tag === t)).map((name) => ({ name })),
355 paths,
356 components: {
357 securitySchemes: {
358 token: { type: "http", scheme: "bearer", description: "A Diffinery personal access token (`dfy_...`) from Settings → Access tokens. `Authorization: token dfy_...` and HTTP Basic (token as the password) also work." },
359 },
360 schemas: {
361 Error: { type: "object", properties: { message: { type: "string" } }, required: ["message"] },
362 },
363 },
364 };
365}
366
367function operationId(op: ApiOperation): string {
368 const parts = op.path
369 .split("/")
370 .filter(Boolean)
371 .map((s) => (s.startsWith("{") ? `by-${s.slice(1, -1)}` : s));
372 return [op.method.toLowerCase(), ...parts]
373 .join("-")
374 .replace(/[^a-zA-Z0-9]+(.)/g, (_, ch: string) => ch.toUpperCase())
375 .replace(/[^a-zA-Z0-9]/g, "");
376}
377
378// ---------------------------------------------------------------- MCP tools
379
380const MCP_GROUPS: Array<[string, RegExp]> = [
381 ["Identity and repositories", /^(whoami|list_repos)$/],
382 ["Code", /^(list_tree|get_file|search_code|commit_files)$/],
383 ["Branches and commits", /branch|commit|compare/],
384 ["Pull requests", /pull_request|review|merge/],
385 ["Issues", /issue|comment|label/],
386 ["Actions", /workflow|run|job|artifact/],
387 ["Releases", /release/],
388 ["Imports", /import/],
389 ["Sandboxes", /sandbox/],
390 ["Docs", /docs/],
391];
392
393function mcpGroup(name: string): string {
394 for (const [g, re] of MCP_GROUPS) if (re.test(name)) return g;
395 return "Other";
396}
397
398type JsonSchemaProp = { type?: string; description?: string; enum?: string[]; items?: unknown };
399
400/** The /docs/reference/mcp-tools page. */
401export function mcpToolsMarkdown(origin: string): string {
402 const tools = mcpTools();
403 const groups = new Map<string, typeof tools>();
404 for (const g of [...MCP_GROUPS.map(([n]) => n), "Other"]) groups.set(g, []);
405 for (const t of tools) groups.get(mcpGroup(t.name))!.push(t);
406 for (const [g, list] of groups) if (!list.length) groups.delete(g);
407 const out: string[] = [
408 `Every tool the Diffinery MCP server offers, generated from the server's own tool list, so it always matches what \`tools/list\` returns. For setup in Claude Code, Cursor and other clients, read [MCP](/docs/agents/mcp).`,
409 "",
410 "## Connection",
411 "",
412 `- Endpoint: \`${origin}/mcp\` (Streamable HTTP; responses are JSON, not event streams).`,
413 "- Authentication: `Authorization: Bearer dfy_...`, a token for an agent or a person. Every call needs an active account.",
414 `- Protocol versions: ${PROTOCOL_VERSIONS.map((v) => `\`${v}\``).join(", ")}. The server answers with the version the client asks for when it supports it, else the newest.`,
415 "- Every tool runs through the same permission checks, branch protection and agent guardrails as git and the REST API.",
416 "- Errors come back as a tool result with `isError: true` and the message as text. Successful results carry the JSON both as text and as `structuredContent`.",
417 "",
418 "## Tools",
419 "",
420 "| Tool | Read-only | Summary |",
421 "| --- | --- | --- |",
422 ...tools.map((t) => `| [\`${t.name}\`](#${slug(t.name)}) | ${t.annotations.readOnlyHint ? "yes" : "no"} | ${cell(firstSentence(t.description))} |`),
423 "",
424 ];
425 for (const [g, list] of groups) {
426 out.push(`## ${g}`, "");
427 for (const t of list) {
428 out.push(`### ${t.name}`, "", t.description, "", `Read-only: ${t.annotations.readOnlyHint ? "yes" : "no"}.`, "");
429 const props = Object.entries(t.inputSchema.properties as Record<string, JsonSchemaProp>);
430 if (props.length) {
431 out.push("| Argument | Type | Required | Description |", "| --- | --- | --- | --- |");
432 for (const [k, p] of props) {
433 const type = p.type === "array" && p.items && typeof p.items === "object" ? `array of ${(p.items as { type?: string }).type ?? "object"}` : (p.type ?? "any");
434 out.push(`| \`${k}\` | ${type}${p.enum ? ` (${p.enum.map((e) => `\`${e}\``).join(", ")})` : ""} | ${t.inputSchema.required.includes(k) ? "yes" : "no"} | ${cell(p.description ?? "")} |`);
435 }
436 out.push("");
437 } else out.push("No arguments.", "");
438 const args = Object.fromEntries(t.inputSchema.required.map((k) => [k, EXAMPLE[k] ?? (k.includes("number") ? 42 : "...")]));
439 out.push("```json", JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: t.name, arguments: args } }), "```", "");
440 }
441 }
442 return out.join("\n");
443}
444
445function firstSentence(s: string): string {
446 const m = /^(.+?\.)(\s|$)/.exec(s);
447 return m ? m[1]! : s;
448}