import { z } from "zod"; import { handleEditorRoutes } from "../../editor/api"; import { mintEditorToken } from "../../editor/session"; import { HttpError } from "../../errors"; import { credentialAllows } from "../../token-scope"; import type { McpCaller } from "../protocol"; import { defineTool, repoArgs, scopedRepo, withTrailers } from "./base"; import { resolveRef } from "./content"; const MAX_EDITS = 200; /** The room answers stale edits with 409 plus the current text; tools return that to the agent. */ const STALE_STATUS = 409; const path = z.string().min(1).max(4096).describe("File path in the repository, e.g. src/app.ts"); const ref = z.string().min(1).max(250).optional().describe("Branch to co-edit (default branch when omitted)"); /** * Mints a short-lived editor token for this agent, so every tool call goes through the same * /api/editor checks as browsers: live permission and PAT scope, branch protection, LFS and path guards. */ async function editorToken(caller: McpCaller, owner: string, repoName: string, refName: string | undefined) { const { repo, permission } = await scopedRepo(caller, owner, repoName, "contents", "read"); const revision = await resolveRef(repo, refName); if (revision.kind !== "branch") throw new HttpError(400, "Live editing needs a branch"); const grant = caller.credential.grant; const minted = await mintEditorToken(caller.viewer, repo, revision.name, { ceiling: permission, canOpenPull: await credentialAllows(repo, caller.credential, "pulls", "write"), patId: grant && grant.kind !== "deploy" ? grant.tokenId : null, }); return minted.token; } async function editorCall( caller: McpCaller, token: string, action: string, body?: unknown, search?: Record, ) { const url = new URL(`${caller.origin}/api/editor/${action}`); for (const [key, value] of Object.entries(search ?? {})) url.searchParams.set(key, value); const request = new Request(url, { method: body === undefined ? "GET" : "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const response = await handleEditorRoutes(request, url); if (!response) throw new HttpError(404, "Editor endpoint not found"); const result = (await response.json()) as Record; if (!response.ok && response.status !== STALE_STATUS) throw new HttpError(response.status, String(result.error ?? "Editor request failed")); return result; } const editShape = z.object({ from: z.number().int().nonnegative().describe("UTF-16 start offset in the text you last read"), to: z.number().int().nonnegative().describe("UTF-16 end offset (exclusive); equal to from for a pure insert"), insert: z.string().max(1024 * 1024), }); export const editorTools = [ defineTool({ name: "join_edit_room", title: "Join a live editing room", description: "Read a file's live text from the branch's co-editing room (what humans in the browser editor see right now, including unsaved edits), with its version and the participants. Use the version as base_version for apply_edits.", input: z.object({ ...repoArgs, ref, path }), write: false, async run(ctx, args) { const token = await editorToken(ctx.caller, args.owner, args.repo, args.ref); const snapshot = await editorCall(ctx.caller, token, "room/file", undefined, { path: args.path }); return { structured: { room: `${args.owner}/${args.repo}@${args.ref ?? "default"}`, ...snapshot } }; }, }), defineTool({ name: "apply_edits", title: "Apply edits in a live editing room", description: "Apply ranged text edits to a file in the branch's co-editing room. Humans see them immediately with your cursor. Edits are offsets against the text at base_version; a stale base_version returns ok=false with the current text and version so you can rebase and retry. Nothing is committed until commit_edit_room.", input: z.object({ ...repoArgs, ref, path, base_version: z.number().int().nonnegative().optional(), edits: z.array(editShape).min(1).max(MAX_EDITS), }), write: true, async run(ctx, args) { const token = await editorToken(ctx.caller, args.owner, args.repo, args.ref); const result = await editorCall(ctx.caller, token, "room/edit", { path: args.path, agent: ctx.caller.agent.name.slice(0, 60).replace(/[^\w .@-]/g, "_") || "agent", baseVersion: args.base_version, edits: args.edits, }); return { structured: result }; }, }), defineTool({ name: "leave_edit_room", title: "Leave a live editing room", description: "Remove your cursor and presence from the branch's co-editing room.", input: z.object({ ...repoArgs, ref }), write: false, async run(ctx, args) { const token = await editorToken(ctx.caller, args.owner, args.repo, args.ref); await editorCall(ctx.caller, token, "room/leave", { agent: ctx.caller.agent.name.slice(0, 60).replace(/[^\w .@-]/g, "_") || "agent", }); return { structured: { ok: true } }; }, }), defineTool({ name: "commit_edit_room", title: "Commit a live editing room", description: "Commit everything pending in the branch's co-editing room (everyone's edits) to the branch. If the branch is protected, the commit goes to a new branch and a pull request is opened.", input: z.object({ ...repoArgs, ref, message: z.string().min(1).max(10_000) }), write: true, async run(ctx, args) { const token = await editorToken(ctx.caller, args.owner, args.repo, args.ref); const result = await editorCall(ctx.caller, token, "commit", { message: withTrailers(args.message, ctx.caller), fromRoom: true, }); return { structured: result }; }, }), ];