Vectrum
Sign In
157 lines · 7.0 KB · TypeScript
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),
});