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.

438 lines · 23.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 ["Notifications and email", /^\/notifications$|\/subscription$|\/diffinery\/(notification-settings|emails|admin)/],
63 ["Tokens and agents", /\/diffinery\/(tokens|agents)/],
64 ["Sandboxes", /\/diffinery\/sandboxes/],
65];
66export const TAG_ORDER = [...TAGS.map(([t]) => t), "Other"];
67
68function tagFor(path: string): string {
69 for (const [tag, re] of TAGS) if (re.test(path)) return tag;
70 return "Other";
71}
72
73const words = (s: string) => s.replace(/[-_]/g, " ");
74const singular = (s: string) => s.replace(/ies$/, "y").replace(/(ss)$/, "$1").replace(/s$/, "");
75
76/** A readable summary for an endpoint nobody has written notes for yet. */
77export function derivedSummary(method: string, path: string): string {
78 const segs = path.split("/").filter(Boolean);
79 const last = segs[segs.length - 1] ?? "";
80 const endsWithParam = last.startsWith("{");
81 const noun = words([...segs].reverse().find((s) => !s.startsWith("{")) ?? "resource");
82 const one = `a ${singular(noun)}`;
83 switch (method) {
84 case "GET":
85 return endsWithParam ? `Get ${one}` : `List ${noun}`;
86 case "POST":
87 return endsWithParam ? `Run ${words(last.slice(1, -1))} on ${one}` : `Create ${one}`;
88 case "PUT":
89 return endsWithParam ? `Set ${one}` : `Set ${noun}`;
90 case "PATCH":
91 return `Update ${endsWithParam ? one : noun}`;
92 case "DELETE":
93 return `Delete ${endsWithParam ? one : noun}`;
94 default:
95 return `${method} ${path}`;
96 }
97}
98
99let opsCache: ApiOperation[] | null = null;
100
101/** Every REST endpoint the Worker serves under /api/v3. */
102export function apiOperations(): ApiOperation[] {
103 if (opsCache) return opsCache;
104 const seen = new Set<string>();
105 const ops: ApiOperation[] = [];
106 for (const r of app.routes) {
107 if (!METHOD_ORDER.includes(r.method) || !(r.path === API_BASE || r.path.startsWith(`${API_BASE}/`))) continue;
108 const path = openApiPath(r.path.slice(API_BASE.length)) || "/";
109 const key = `${r.method} ${path}`;
110 if (seen.has(key)) continue;
111 seen.add(key);
112 const doc = API_DOCS[key];
113 ops.push({
114 method: r.method,
115 path,
116 params: [...path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!),
117 tag: tagFor(path),
118 doc: doc ?? { summary: derivedSummary(r.method, path) },
119 documented: !!doc,
120 // A token's scope also caps its repository role (write → maintain), so admin endpoints need an admin token.
121 scope: ACCOUNT_SCOPES[key]?.scope ?? (doc?.access === "admin" ? "admin" : r.method === "GET" ? "read" : "write"),
122 accountWide: !!ACCOUNT_SCOPES[key] && !ACCOUNT_SCOPES[key]!.anyToken,
123 sessionOnly: !!ACCOUNT_SCOPES[key]?.sessionOnly,
124 });
125 }
126 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));
127 // Only cache once the web UI is mounted too (index.ts mounts it after app.ts loads).
128 if (ops.length) opsCache = ops;
129 return ops;
130}
131
132export const ACCESS_TEXT: Record<Access, string> = {
133 public: "No authentication needed.",
134 account: "Any active account (a token, or a browser session).",
135 read: "Read access to the repository. Public repositories allow anyone to read.",
136 triage: "Triage access to the repository.",
137 write: "Write access to the repository.",
138 maintain: "Maintain access to the repository.",
139 admin: "Admin access to the repository (or to the package).",
140 "org-admin": "An owner or admin of the organization (or a site admin).",
141 "org-owner": "An owner of the organization (or a site admin).",
142 "site-admin": "A site administrator.",
143};
144
145/** What a personal access token needs; browser sessions always have full scope. */
146export function scopeText(op: Pick<ApiOperation, "scope" | "accountWide" | "sessionOnly" | "doc">): string {
147 if (op.doc.access === "public") return "";
148 if (op.sessionOnly) return "No token can call it: it needs a browser session.";
149 return `Tokens need the \`${op.scope}\` scope${op.accountWide ? " and access to all repositories" : ""}.`;
150}
151
152const PARAM_TEXT: Record<string, string> = {
153 owner: "Account (user or organization) that owns the repository or package.",
154 repo: "Repository name.",
155 org: "Organization login.",
156 login: "Account login.",
157 number: "Issue or pull request number.",
158 branch: "Branch name (for protection rules: a branch name or pattern).",
159 ref: "Branch, tag or commit SHA.",
160 path: "Path inside the repository, or a ref name after `refs/`.",
161 spec: "Two refs as `base...head`.",
162 id: "Identifier.",
163 name: "Name.",
164 type: "Package type: `container`.",
165 workflow: "Workflow file path (URL-encoded) or file name.",
166 runId: "Workflow run id, or the run number (`github.run_id`).",
167 format: "Archive format: `zip`.",
168 action: "Action to take.",
169};
170
171const 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" };
172
173function curl(origin: string, op: ApiOperation): string {
174 const url = `${origin}${API_BASE}${op.path === "/" ? "" : op.path.replace(/\{(\w+)\}/g, (_, p: string) => EXAMPLE[p] ?? (p === "id" ? "1" : p))}`;
175 const lines = [`curl${op.method === "GET" ? "" : ` -X ${op.method}`} "${url}"`];
176 if (op.doc.access !== "public") lines.push(` -H "Authorization: Bearer $DIFFINERY_TOKEN"`);
177 const required = Object.entries(op.doc.body ?? {}).filter(([, f]) => f[2]);
178 if (required.length) {
179 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 : "..."]));
180 lines.push(` -H "Content-Type: application/json"`, ` -d '${JSON.stringify(sample)}'`);
181 }
182 return lines.join(" \\\n");
183}
184
185const cell = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
186
187function fieldTable(title: string, fields: Record<string, Field>): string[] {
188 return [`${title}:`, "", "| Name | Type | Required | Description |", "| --- | --- | --- | --- |", ...Object.entries(fields).map(([k, [t, d, req]]) => `| \`${k}\` | ${t} | ${req ? "yes" : "no"} | ${cell(d)} |`), ""];
189}
190
191/** The /docs/api page: every endpoint, grouped, with parameters and an example. */
192export function apiReferenceMarkdown(origin: string): string {
193 const ops = apiOperations();
194 const byTag = new Map<string, ApiOperation[]>();
195 for (const op of ops) byTag.set(op.tag, [...(byTag.get(op.tag) ?? []), op]);
196 const used = new Set<string>();
197 const heading = (op: ApiOperation) => {
198 let h = op.doc.summary;
199 if (used.has(h.toLowerCase())) h = `${h} (${op.method} ${op.path.replace(/[{}]/g, "")})`;
200 used.add(h.toLowerCase());
201 return h;
202 };
203 const out: string[] = [
204 `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).`,
205 "",
206 "## Basics",
207 "",
208 `- Base URL: \`${origin}${API_BASE}\``,
209 "- 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*.",
210 "- Send and receive JSON. Errors are JSON objects with a `message` and a `documentation_url`, unknown paths included (404).",
211 "- 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.",
212 "- 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.",
213 "- 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.",
214 "- 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).",
215 "- 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.",
216 `- ${ops.length} endpoints in ${byTag.size} groups.`,
217 "",
218 "## Endpoints by group",
219 "",
220 ...[...byTag.entries()].map(([tag, list]) => `- [${tag}](#${slug(tag)}) (${list.length})`),
221 "",
222 ];
223 for (const [tag, list] of byTag) {
224 out.push(`## ${tag}`, "", "| Method | Path | Summary |", "| --- | --- | --- |");
225 const heads = list.map((op) => heading(op));
226 list.forEach((op, i) => out.push(`| ${op.method} | \`${op.path}\` | [${cell(op.doc.summary)}](#${slug(heads[i]!)}) |`));
227 out.push("");
228 list.forEach((op, i) => {
229 out.push(`### ${heads[i]}`, "", "```http", `${op.method} ${API_BASE}${op.path === "/" ? "" : op.path}`, "```", "");
230 if (op.doc.description) out.push(op.doc.description, "");
231 out.push(`**Access:** ${[ACCESS_TEXT[op.doc.access ?? "read"], scopeText(op)].filter(Boolean).join(" ")}${op.doc.status ? ` **Success:** \`${op.doc.status}\`.` : ""}`, "");
232 if (op.params.length) out.push(...fieldTable("Path parameters", Object.fromEntries(op.params.map((p) => [p, ["string", PARAM_TEXT[p] ?? p, true] as Field]))));
233 if (op.doc.query) out.push(...fieldTable("Query parameters", op.doc.query));
234 if (op.doc.body) out.push(...fieldTable("Body (JSON)", op.doc.body));
235 out.push("```sh", curl(origin, op), "```", "");
236 });
237 }
238 out.push(
239 "## Other HTTP endpoints",
240 "",
241 "These aren't part of the JSON API, but clients and tools talk to them directly.",
242 "",
243 "| Endpoint | What it is |",
244 "| --- | --- |",
245 ...OTHER_ENDPOINTS.map(([path, what]) => `| \`${path}\` | ${what} |`),
246 "",
247 );
248 return out.join("\n");
249}
250
251/** Non-API endpoints worth knowing. A unit test checks that each one is still routed. */
252export const OTHER_ENDPOINTS: Array<[string, string]> = [
253 ["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)."],
254 ["POST /{owner}/{repo}.git/git-upload-pack", "Git smart HTTP: fetch and clone."],
255 ["POST /{owner}/{repo}.git/git-receive-pack", "Git smart HTTP: push."],
256 ["POST /{owner}/{repo}.git/info/lfs/objects/batch", "Git LFS batch API. See [Git LFS](/docs/repositories/git-lfs)."],
257 ["GET, PUT /{owner}/{repo}.git/info/lfs/objects/{oid}", "Git LFS object download and upload."],
258 ["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."],
259 ["POST /mcp", "The MCP server (Streamable HTTP, JSON-RPC). See [MCP](/docs/agents/mcp) and the [MCP tool reference](/docs/reference/mcp-tools)."],
260 ["/v2/...", "The OCI container registry (Docker Registry HTTP API v2). See [Container registry](/docs/packages/container-registry)."],
261 ["GET /.well-known/openid-configuration", "OIDC discovery for workflow ID tokens. See [OIDC](/docs/actions/oidc)."],
262 ["GET /.well-known/jwks", "Public keys that sign workflow ID tokens."],
263 ["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)."],
264 ["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)."],
265 ["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)."],
266 ["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)."],
267 ["GET /healthz", "Health check: `{\"ok\": true}`."],
268 ["GET /llms.txt", "Index of these docs for AI agents. See [Docs for AI agents](/docs/agents/docs-for-agents)."],
269 ["GET /llms-full.txt", "All of these docs in one Markdown file."],
270 ["GET /docs/{page}.md", "Any docs page as Markdown."],
271 ["GET /docs/api/openapi.json", "This reference as an OpenAPI 3.1 document."],
272];
273
274// Same rules as slugify() in src/web/markdown.ts.
275function slug(text: string): string {
276 return text
277 .toLowerCase()
278 .replace(/[^\p{L}\p{N}\s_-]/gu, "")
279 .trim()
280 .replace(/\s+/g, "-")
281 .slice(0, 80);
282}
283
284// ---------------------------------------------------------------- OpenAPI
285
286function schemaFor(type: string): Record<string, unknown> {
287 if (type.endsWith("[]")) return { type: "array", items: schemaFor(type.slice(0, -2)) };
288 if (["string", "integer", "boolean", "object", "array", "number"].includes(type)) return { type };
289 return {};
290}
291
292/** The REST API as an OpenAPI 3.1 document. */
293export function openApiDocument(origin: string, version: string): Record<string, unknown> {
294 const paths: Record<string, Record<string, unknown>> = {};
295 for (const op of apiOperations()) {
296 const params = [
297 ...op.params.map((p) => ({ name: p, in: "path", required: true, description: PARAM_TEXT[p] ?? p, schema: { type: "string" } })),
298 ...Object.entries(op.doc.query ?? {}).map(([name, [t, d, req]]) => ({ name, in: "query", required: !!req, description: d, schema: schemaFor(t) })),
299 ];
300 const body = op.doc.body
301 ? {
302 required: Object.values(op.doc.body).some((f) => f[2]),
303 content: {
304 "application/json": {
305 schema: {
306 type: "object",
307 properties: Object.fromEntries(Object.entries(op.doc.body).map(([k, [t, d]]) => [k, { ...schemaFor(t), description: d }])),
308 required: Object.entries(op.doc.body)
309 .filter(([, f]) => f[2])
310 .map(([k]) => k),
311 },
312 },
313 },
314 }
315 : undefined;
316 const ok = String(op.doc.status ?? 200);
317 const access = op.doc.access ?? "read";
318 (paths[op.path] ??= {})[op.method.toLowerCase()] = {
319 operationId: operationId(op),
320 summary: op.doc.summary,
321 description: [op.doc.description, `Access: ${[ACCESS_TEXT[access], scopeText(op)].filter(Boolean).join(" ")}`].filter(Boolean).join("\n\n"),
322 tags: [op.tag],
323 ...(params.length ? { parameters: params } : {}),
324 ...(body ? { requestBody: body } : {}),
325 ...(access === "public" ? { security: [] } : {}),
326 "x-diffinery-access": access,
327 "x-diffinery-token-scope": op.sessionOnly ? "none" : op.scope,
328 responses: {
329 [ok]: { description: ok === "204" ? "No content" : "Success", ...(ok === "204" || ok === "205" ? {} : { content: { "application/json": { schema: {} } } }) },
330 default: { description: "Error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
331 },
332 };
333 }
334 return {
335 openapi: "3.1.0",
336 info: {
337 title: "Diffinery REST API",
338 version,
339 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`,
340 },
341 externalDocs: { description: "Diffinery docs", url: `${origin}/docs` },
342 servers: [{ url: `${origin}${API_BASE}` }],
343 security: [{ token: [] }],
344 tags: TAG_ORDER.filter((t) => apiOperations().some((o) => o.tag === t)).map((name) => ({ name })),
345 paths,
346 components: {
347 securitySchemes: {
348 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." },
349 },
350 schemas: {
351 Error: { type: "object", properties: { message: { type: "string" } }, required: ["message"] },
352 },
353 },
354 };
355}
356
357function operationId(op: ApiOperation): string {
358 const parts = op.path
359 .split("/")
360 .filter(Boolean)
361 .map((s) => (s.startsWith("{") ? `by-${s.slice(1, -1)}` : s));
362 return [op.method.toLowerCase(), ...parts]
363 .join("-")
364 .replace(/[^a-zA-Z0-9]+(.)/g, (_, ch: string) => ch.toUpperCase())
365 .replace(/[^a-zA-Z0-9]/g, "");
366}
367
368// ---------------------------------------------------------------- MCP tools
369
370const MCP_GROUPS: Array<[string, RegExp]> = [
371 ["Identity and repositories", /^(whoami|list_repos)$/],
372 ["Code", /^(list_tree|get_file|search_code|commit_files)$/],
373 ["Branches and commits", /branch|commit|compare/],
374 ["Pull requests", /pull_request|review|merge/],
375 ["Issues", /issue|comment|label/],
376 ["Actions", /workflow|run|job|artifact/],
377 ["Releases", /release/],
378 ["Imports", /import/],
379 ["Sandboxes", /sandbox/],
380 ["Docs", /docs/],
381];
382
383function mcpGroup(name: string): string {
384 for (const [g, re] of MCP_GROUPS) if (re.test(name)) return g;
385 return "Other";
386}
387
388type JsonSchemaProp = { type?: string; description?: string; enum?: string[]; items?: unknown };
389
390/** The /docs/reference/mcp-tools page. */
391export function mcpToolsMarkdown(origin: string): string {
392 const tools = mcpTools();
393 const groups = new Map<string, typeof tools>();
394 for (const g of [...MCP_GROUPS.map(([n]) => n), "Other"]) groups.set(g, []);
395 for (const t of tools) groups.get(mcpGroup(t.name))!.push(t);
396 for (const [g, list] of groups) if (!list.length) groups.delete(g);
397 const out: string[] = [
398 `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).`,
399 "",
400 "## Connection",
401 "",
402 `- Endpoint: \`${origin}/mcp\` (Streamable HTTP; responses are JSON, not event streams).`,
403 "- Authentication: `Authorization: Bearer dfy_...`, a token for an agent or a person. Every call needs an active account.",
404 `- 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.`,
405 "- Every tool runs through the same permission checks, branch protection and agent guardrails as git and the REST API.",
406 "- 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`.",
407 "",
408 "## Tools",
409 "",
410 "| Tool | Read-only | Summary |",
411 "| --- | --- | --- |",
412 ...tools.map((t) => `| [\`${t.name}\`](#${slug(t.name)}) | ${t.annotations.readOnlyHint ? "yes" : "no"} | ${cell(firstSentence(t.description))} |`),
413 "",
414 ];
415 for (const [g, list] of groups) {
416 out.push(`## ${g}`, "");
417 for (const t of list) {
418 out.push(`### ${t.name}`, "", t.description, "", `Read-only: ${t.annotations.readOnlyHint ? "yes" : "no"}.`, "");
419 const props = Object.entries(t.inputSchema.properties as Record<string, JsonSchemaProp>);
420 if (props.length) {
421 out.push("| Argument | Type | Required | Description |", "| --- | --- | --- | --- |");
422 for (const [k, p] of props) {
423 const type = p.type === "array" && p.items && typeof p.items === "object" ? `array of ${(p.items as { type?: string }).type ?? "object"}` : (p.type ?? "any");
424 out.push(`| \`${k}\` | ${type}${p.enum ? ` (${p.enum.map((e) => `\`${e}\``).join(", ")})` : ""} | ${t.inputSchema.required.includes(k) ? "yes" : "no"} | ${cell(p.description ?? "")} |`);
425 }
426 out.push("");
427 } else out.push("No arguments.", "");
428 const args = Object.fromEntries(t.inputSchema.required.map((k) => [k, EXAMPLE[k] ?? (k.includes("number") ? 42 : "...")]));
429 out.push("```json", JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: t.name, arguments: args } }), "```", "");
430 }
431 }
432 return out.join("\n");
433}
434
435function firstSentence(s: string): string {
436 const m = /^(.+?\.)(\s|$)/.exec(s);
437 return m ? m[1]! : s;
438}