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; 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; 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); } /** Git trailers naming the agent and the human who authorized it, appended to every agent commit. */ export function attributionTrailers(caller: McpCaller): string { return `Agent: ${caller.agent.name}\nAuthorized-by: @${caller.agent.authorizedBy}`; } export function withTrailers(message: string, caller: McpCaller): string { return `${message.trimEnd()}\n\n${attributionTrailers(caller)}`; } export function attributionFooter(caller: McpCaller): string { return `\n\n---\n_Opened by agent **${caller.agent.name.replaceAll("*", "")}** on behalf of @${caller.agent.authorizedBy}._`; } 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}`; }