import { z } from "zod"; import { HttpError } from "../../errors"; import type { ApiContext, ScopedRepo } from "../../api/http"; import { requireScopedRepo } from "../../api/http"; import type { TokenArea } from "../../token-scope"; import type { JsonObject, McpCaller } from "../protocol"; import type { Permission } from "~/lib/types"; export const MAX_TEXT_BYTES = 64 * 1024; export type ResourceLink = { type: "resource_link"; uri: string; name: string; mimeType?: string; description?: string; }; export type ToolResult = { structured: Record; text?: string; links?: ResourceLink[]; repoId?: string | null; }; /** A tool asking the client for input first (2026 multi round-trip requests, e.g. a confirmation). */ export type InputRequired = { inputRequired: Record }; export type ToolContext = { caller: McpCaller; signal: AbortSignal; progress: (message: string, progress: number, total?: number) => void; inputResponses: JsonObject | null; }; export type Tool = { name: string; title: string; description: string; input: z.ZodObject; output?: z.ZodType; write: boolean; /** A write an unverified account may still make (scratch sites, which are anonymous anyway). */ unverifiedOk?: boolean; destructive?: boolean; run: (ctx: ToolContext, args: unknown) => Promise; }; export function defineTool(def: { name: string; title: string; description: string; input: S; output?: z.ZodType; write: boolean; unverifiedOk?: boolean; destructive?: boolean; run: (ctx: ToolContext, args: z.infer) => Promise; }): Tool { return { ...def, run: (ctx, args) => def.run(ctx, def.input.parse(args)) }; } /** Raised for user-facing tool failures; `denied` marks permission refusals in the audit log. */ export class ToolError extends Error { constructor( message: string, readonly denied = false, ) { super(message); } } export const owner = z.string().min(1).max(100).describe("Repository owner (user or organization)"); export const repo = z.string().min(1).max(100).describe("Repository name"); export const repoArgs = { owner, repo }; /** An API context for the caller, so tools reuse src/server/api handlers and their permission checks verbatim. */ export function apiCtx( caller: McpCaller, init: { path?: string; body?: unknown; search?: Record } = {}, ) { const url = new URL(`${caller.origin}/api/v1${init.path ?? "/"}`); for (const [key, value] of Object.entries(init.search ?? {})) url.searchParams.set(key, value); const request = new Request(url, { method: init.body === undefined ? "GET" : "POST", headers: { "content-type": "application/json" }, body: init.body === undefined ? undefined : JSON.stringify(init.body), }); return { request, url, credential: caller.credential, viewer: caller.viewer } satisfies ApiContext; } export async function apiJson(response: Response | Promise): Promise { const resolved = await response; const text = await resolved.text(); const body: unknown = text ? JSON.parse(text) : null; if (!resolved.ok) { const message = typeof body === "object" && body !== null && "error" in body ? String(body.error) : `HTTP ${resolved.status}`; throw new HttpError(resolved.status, message); } return body as T; } export async function scopedRepo( caller: McpCaller, ownerName: string, repoName: string, area: TokenArea, needed: Permission, ): Promise { return requireScopedRepo(apiCtx(caller), ownerName, repoName, area, needed); } function plain(value: string): string { return value.replace(/[\r\n*_`]/g, " ").trim(); } /** Git trailers naming the agent (from its token) and the human who authorized it. */ export function attributionTrailers(caller: McpCaller): string { const lines = [`Agent: ${plain(caller.agent.name)}`, `Authorized-by: @${caller.agent.authorizedBy}`]; if (caller.agent.claimedName) lines.push(`Agent-Claimed-Name: ${plain(caller.agent.claimedName)} (unverified)`); return lines.join("\n"); } export function withTrailers(message: string, caller: McpCaller): string { return `${message.trimEnd()}\n\n${attributionTrailers(caller)}`; } /** One-line markdown attribution for issue, pull request and review bodies. */ export function attributionNote(caller: McpCaller, verb: string): string { const claim = caller.agent.claimedName ? ` (calls itself "${plain(caller.agent.claimedName)}", unverified)` : ""; return `_${verb} by agent **${plain(caller.agent.name)}**${claim} on behalf of @${caller.agent.authorizedBy}._`; } export function attributionFooter(caller: McpCaller): string { return `\n\n---\n${attributionNote(caller, "Opened")}`; } export function truncateText(text: string, limit = MAX_TEXT_BYTES): { text: string; truncated: boolean } { if (text.length <= limit) return { text, truncated: false }; return { text: text.slice(0, limit), truncated: true }; } export function fileUri(ownerName: string, repoName: string, ref: string, path: string): string { const encodedPath = path.split("/").map(encodeURIComponent).join("/"); return `gitfor://${encodeURIComponent(ownerName)}/${encodeURIComponent(repoName)}/${encodeURIComponent(ref)}/${encodedPath}`; } /** * Human confirmation for privileged writes (approve, merge, mint token). When the client supports * elicitation the tool first returns `inputRequired`; on the second round-trip the answer is checked. * Returns `null` when confirmed or not applicable; callers must treat a returned `InputRequired` as the result. */ export function confirmWithHuman( ctx: Pick, key: string, message: string, ): InputRequired | null { if (!ctx.caller.clientCapabilities.elicitation || ctx.caller.era !== "modern") return null; const answer = ctx.inputResponses?.[key]; if (!answer) { return { inputRequired: { [key]: { method: "elicitation/create", params: { mode: "form", message, requestedSchema: { type: "object", properties: { approve: { type: "boolean", title: "Approve" } }, required: ["approve"], }, }, }, }, }; } const approved = typeof answer === "object" && answer !== null && Reflect.get(answer, "action") === "accept" && Reflect.get(Reflect.get(answer, "content") ?? {}, "approve") === true; if (!approved) throw new ToolError("The action was declined", true); return null; } /** Whether a tool call can be confirmed by a human at all (elicitation-capable modern client). */ export function canConfirmWithHuman(caller: McpCaller): boolean { return Boolean(caller.clientCapabilities.elicitation) && caller.era === "modern"; }