import "server-only";
import type { Actor, AgentRef } from "../actor";
import { defaultBranch, normalizeRepoName, revParse, type FileDiff } from "../git";
import { cleanRepoPath } from "../git-write";
import { priorityLabel, type IssueSummary } from "../issue-meta";
import { afterRefUpdated } from "../push";
import { canWrite, findRepo, repoAccess, type RepoRecord } from "../repos";
import { tokenCovers, type TokenScope } from "../tokens";
import type { User } from "../users";
import { objectSchema, parseArgs, string, ToolError, type Args, type Props } from "./params";
// The tools an agent gets on /mcp. Each checks the user's access to the repository the way the web
// UI does, and on top of that what the connection was approved for (lib/oauth.ts or an API token).
export type ToolContext = {
user: User;
scope: TokenScope;
origin: string;
/** The connection itself: everything a tool changes is recorded as this agent's. */
agent: AgentRef;
/** Aborted when the client goes away, for tools that wait. */
signal?: AbortSignal;
};
/** Who acts when a tool changes something: the agent, for its person. */
export const actorOf = (ctx: ToolContext): Actor => ({ userId: ctx.user.id, agent: ctx.agent });
export type Tool = {
name: string;
title: string;
description: string;
/** "write" tools change something; read-only connections don't see them. */
access: "read" | "write";
destructive?: boolean;
idempotent?: boolean;
inputSchema: Record<string, unknown>;
call: (args: unknown, ctx: ToolContext) => Promise<unknown>;
};
export function tool<P extends Props>(def: Omit<Tool, "inputSchema" | "call"> & { params: P; run: (args: Args<P>, ctx: ToolContext) => Promise<unknown> }): Tool {
const { params, run, ...rest } = def;
return { ...rest, inputSchema: objectSchema(params), call: (args, ctx) => run(parseArgs(params, args), ctx) };
}
/** Longest text a tool answers with, so one call can't flood the agent's context. */
export const MAX_OUTPUT = 100_000;
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
export const ZERO_SHA = "0".repeat(40);
export const iso = (ms: number | null) => (ms ? new Date(ms).toISOString() : null);
export function truncate(text: string, hint: string) {
return text.length <= MAX_OUTPUT ? text : `${text.slice(0, MAX_OUTPUT)}\n\n[Output truncated at ${MAX_OUTPUT} characters. ${hint}]`;
}
// ---------------------------------------------------------------------------
// Access
/** read: see it; comment: write to it as any reader may (issues, comments); write: push access; admin: the owner. */
export type Need = "read" | "comment" | "write" | "admin";
export async function openRepo(ctx: ToolContext, owner: string, name: string, need: Need) {
const repo = await findRepo(owner, normalizeRepoName(name));
const access = repo ? await repoAccess(repo, ctx.user.id) : null;
if (!repo || !access) throw new ToolError(`Repository ${owner}/${name} doesn't exist, or you can't see it.`);
const full = `${repo.owner}/${repo.name}`;
if (!tokenCovers(ctx.scope, repo.id, false)) {
throw new ToolError(`This connection doesn't include ${full}. Reconnect and select it, or allow all repositories.`);
}
if ((need === "write" || need === "admin") && !canWrite(access)) throw new ToolError(`You don't have push access to ${full}.`);
if (need === "admin" && access !== "admin") throw new ToolError(`Only the owner of ${full} can change its settings.`);
return { repo, access, full, url: `${ctx.origin}/${full}` };
}
export const covered = (ctx: ToolContext, repos: RepoRecord[]) => repos.filter((r) => tokenCovers(ctx.scope, r.id, false));
export const repoSummary = (ctx: ToolContext, r: RepoRecord) => ({
full_name: `${r.owner}/${r.name}`,
description: r.description || null,
visibility: r.visibility,
// For forks: the repository it was forked from (named if it's public or yours).
...(r.forkOf
? { fork_of: r.forkOf.visibility === "public" || r.forkOf.ownerId === ctx.user.id ? `${r.forkOf.owner}/${r.forkOf.name}` : "a private repository" }
: {}),
created_at: iso(r.createdAt),
pushed_at: iso(r.pushedAt),
url: `${ctx.origin}/${r.owner}/${r.name}`,
});
/** A ref the agent named (or the default branch) and the commit it points to. */
export async function resolveRef(dir: string, ref: string | undefined) {
if (ref === undefined) {
const branch = await defaultBranch(dir);
const sha = branch && (await revParse(dir, `refs/heads/${branch}`));
if (!branch || !sha) throw new ToolError("The repository is empty: it has no commits yet.");
return { ref: branch, sha };
}
const sha = await revParse(dir, ref);
if (!sha) throw new ToolError(`There's no branch, tag or commit named ${ref}.`);
return { ref, sha };
}
/** A path inside the repository; "" is the root. */
export function repoPath(input: string | undefined) {
const trimmed = (input ?? "").replace(/^\/+|\/+$/g, "");
if (!trimmed) return "";
const path = cleanRepoPath(trimmed);
if (!path) throw new ToolError(`${input} isn't a valid path.`);
return path;
}
/** What follows a ref update (refs/heads/… or refs/tags/…) made by a tool, as for a push: events, workflows, pull requests. */
export function afterPush(repo: RepoRecord, ctx: ToolContext, ref: string, before: string | null, after: string | null) {
afterRefUpdated(repo, ref, before, after, actorOf(ctx));
}
// Parameters most tools take.
export const owner = string("The repository's owner (a username).", { min: 1, max: 39 });
export const repo = string("The repository's name.", { min: 1, max: 100 });
export const ref = string("A branch, tag or commit SHA. Defaults to the default branch.", { max: 255 }).optional();
export function renderDiff(files: FileDiff[]) {
return files
.map((f) => {
const head = [`diff --git a/${f.oldPath} b/${f.newPath}`];
if (f.status === "added") head.push("new file");
if (f.status === "deleted") head.push("deleted file");
if (f.status === "renamed") head.push(`rename from ${f.oldPath}`, `rename to ${f.newPath}`);
if (f.binary) return [...head, "Binary files differ"].join("\n");
head.push(f.status === "added" ? "--- /dev/null" : `--- a/${f.oldPath}`, f.status === "deleted" ? "+++ /dev/null" : `+++ b/${f.newPath}`);
const sign = { add: "+", del: "-", ctx: " ", hunk: "" } as const;
return [...head, ...f.lines.map((l) => sign[l.kind] + l.text)].join("\n");
})
.join("\n");
}
/** An issue in tool answers, without its description. */
export const issueSummary = (i: IssueSummary, ctx: ToolContext) => ({
number: i.number,
title: i.title,
status: i.status,
priority: i.priority,
priority_name: priorityLabel(i.priority),
assignee: i.assignee,
...(i.assigneeAgentOf ? { assignee_is_agent_of: i.assigneeAgentOf } : {}),
assigned_to_you: i.assigneeId === ctx.agent.key,
labels: i.labels.map((l) => l.name),
estimate: i.estimate,
due_date: i.dueDate,
author: i.author,
...(i.authorAgent ? { opened_by_agent: i.authorAgent } : {}),
comments: i.commentCount,
created_at: iso(i.createdAt),
updated_at: iso(i.updatedAt),
});