import { z } from "zod"; import { badRequest } from "../../errors"; import { toJsonText } from "../protocol"; import type { McpCaller } from "../protocol"; import { eventJson, eventsUri, repoHub, sessionHub, sessionSubscriber, subscriberOwner } from "../hub-do"; import { normalizeClaimPath } from "../coordination"; import type { Lease } from "../coordination"; import { AGENT_EVENT_TYPES } from "../events"; import { readableEventTypes } from "../event-access"; import { defineTool, repoArgs, scopedRepo } from "./base"; const MAX_CLAIM_KEYS = 100; const DEFAULT_TTL_SECONDS = 15 * 60; const MAX_TTL_SECONDS = 4 * 60 * 60; const MAX_POLL = 100; /** * Claims belong to one token and its token-derived agent name; client-declared names never split or * merge holders, so agents sharing one unregistered PAT share its claims. */ export function holderKey(caller: McpCaller): string { return `${caller.tokenId ?? caller.viewer.id}:${caller.agent.name}`; } function leaseJson(lease: Lease, caller: McpCaller) { return { id: lease.id, kind: lease.kind, key: lease.key, agent: lease.agent, user: lease.user, note: lease.note, mine: lease.holder === holderKey(caller), expires_at: lease.expiresAt, }; } const ttl = z.number().int().min(30).max(MAX_TTL_SECONDS).default(DEFAULT_TTL_SECONDS); const leaseShape = z.object({ id: z.string(), kind: z.enum(["task", "file"]), key: z.string(), agent: z.string(), user: z.string(), note: z.string().nullable(), mine: z.boolean(), expires_at: z.number(), }); const claimOutput = z.object({ granted: z.array(leaseShape), conflicts: z.array(z.object({ key: z.string(), held_by: leaseShape })), }); async function claim( caller: McpCaller, args: { owner: string; repo: string }, kind: "task" | "file", keys: string[], extra: { note: string | null; ttl: number; force: boolean }, ) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "write"); const hub = repoHub(repo.id); const result = await hub.claim({ kind, keys, holder: holderKey(caller), agent: caller.agent.name, user: caller.agent.authorizedBy, note: extra.note, ttlMs: extra.ttl * 1000, force: extra.force, }); if (result.granted.length > 0) { await hub.publish( repo.id, `${repo.owner_name}/${repo.name}`, "claims", toJsonText({ action: "claimed", kind, keys: result.granted.map((lease) => lease.key), agent: caller.agent.name, user: caller.agent.authorizedBy, }), ); } return { structured: claimOutput.parse({ granted: result.granted.map((lease) => leaseJson(lease, caller)), conflicts: result.conflicts.map((item) => ({ key: item.key, held_by: leaseJson(item.lease, caller) })), }), repoId: repo.id, }; } const claimFilesTool = defineTool({ name: "claim_files", title: "Claim files", description: "Advisory lease on files or directories (end with /) you are about to change, with a TTL. Paths another agent already holds come back in conflicts and are not granted unless force=true. Re-claim to renew.", input: z.object({ ...repoArgs, paths: z.array(z.string().min(1).max(4096)).min(1).max(MAX_CLAIM_KEYS), ttl_seconds: ttl, note: z.string().max(500).optional(), force: z.boolean().default(false), }), output: claimOutput, write: true, async run({ caller }, args) { const paths = args.paths.map((path) => { const normalized = normalizeClaimPath(path); if (!normalized) throw badRequest(`Invalid path ${path}`); return normalized; }); return claim(caller, args, "file", [...new Set(paths)], { note: args.note ?? null, ttl: args.ttl_seconds, force: args.force, }); }, }); const claimTaskTool = defineTool({ name: "claim_task", title: "Claim task", description: "Advisory lease on a named task (e.g. an issue number or slug) so other agents skip it.", input: z.object({ ...repoArgs, task: z.string().trim().min(1).max(200), ttl_seconds: ttl, note: z.string().max(500).optional(), }), output: claimOutput, write: true, async run({ caller }, args) { return claim(caller, args, "task", [args.task], { note: args.note ?? null, ttl: args.ttl_seconds, force: false }); }, }); const releaseTool = defineTool({ name: "release_claims", title: "Release claims", description: "Release your claims by id, or all of your claims in the repository when ids is omitted.", input: z.object({ ...repoArgs, ids: z.array(z.string()).max(MAX_CLAIM_KEYS).optional() }), write: true, async run({ caller }, args) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "read"); const hub = repoHub(repo.id); const released = await hub.release(holderKey(caller), args.ids ?? null); if (released > 0) { await hub.publish( repo.id, `${repo.owner_name}/${repo.name}`, "claims", toJsonText({ action: "released", count: released, agent: caller.agent.name, user: caller.agent.authorizedBy, }), ); } return { structured: { released }, repoId: repo.id }; }, }); const listClaimsTool = defineTool({ name: "list_claims", title: "List claims", description: "Every active task and file claim in a repository, with the agent and human holding it.", input: z.object({ ...repoArgs, kind: z.enum(["task", "file"]).optional() }), write: false, async run({ caller }, args) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "read"); const leases = await repoHub(repo.id).leases(args.kind); return { structured: { claims: leases.map((lease) => leaseJson(lease, caller)) }, repoId: repo.id }; }, }); const eventType = z.enum([...AGENT_EVENT_TYPES, "*"]); const subscribeTool = defineTool({ name: "subscribe_events", title: "Subscribe to repository events", description: "Get push, review, CI and claim events. On a session (2025-era) they are pushed as notifications/gitfor/event on the GET stream; otherwise poll_events from the returned cursor or subscriptions/listen on the events resource.", input: z.object({ ...repoArgs, events: z .array(eventType) .min(1) .max(AGENT_EVENT_TYPES.length + 1) .default(["*"]), }), write: false, async run({ caller }, args) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "read"); const fullName = `${repo.owner_name}/${repo.name}`; let pushed = false; let cursor = (await repoHub(repo.id).eventsSince(Number.MAX_SAFE_INTEGER, 0, null)).latest; if (caller.sessionId) { cursor = await repoHub(repo.id).subscribe( sessionSubscriber(caller.sessionId), args.events, subscriberOwner(caller), ); const session = await sessionHub(caller.sessionId).session(); if (session) { await sessionHub(caller.sessionId).updateSession({ repos: [...new Set([...session.repos, repo.id])], toolEvents: { ...session.toolEvents, [repo.id]: args.events }, }); } pushed = true; } return { structured: { repo: fullName, uri: eventsUri(fullName), events: args.events, cursor, pushed }, repoId: repo.id, }; }, }); const unsubscribeTool = defineTool({ name: "unsubscribe_events", title: "Unsubscribe from repository events", description: "Stop pushing this repository's events to the current session.", input: z.object(repoArgs), write: false, async run({ caller }, args) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "read"); if (caller.sessionId) { const hub = sessionHub(caller.sessionId); const session = await hub.session(); if (session) { const { [repo.id]: _removed, ...toolEvents } = session.toolEvents; await hub.updateSession({ toolEvents }); if (!session.resourceUris.includes(eventsUri(`${repo.owner_name}/${repo.name}`))) { await repoHub(repo.id).unsubscribe(sessionSubscriber(caller.sessionId)); } } } return { structured: { unsubscribed: caller.sessionId !== null }, repoId: repo.id }; }, }); const pollTool = defineTool({ name: "poll_events", title: "Poll repository events", description: "Events after a cursor (0 for the most recent history). Returns the next cursor.", input: z.object({ ...repoArgs, after: z.number().int().min(0).default(0), events: z .array(eventType) .max(AGENT_EVENT_TYPES.length + 1) .optional(), limit: z.number().int().min(1).max(MAX_POLL).default(50), }), write: false, async run({ caller }, args) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "read"); const readable = await readableEventTypes(repo, caller.credential); const wanted = (args.events ?? ["*"]).includes("*") ? [...readable] : (args.events ?? []).filter((type) => readable.has(type)); const { events, latest } = await repoHub(repo.id).eventsSince(args.after, args.limit, wanted); return { structured: { events: events.map(eventJson), cursor: events.at(-1)?.seq ?? latest, latest }, repoId: repo.id, }; }, }); export const coordinationTools = [ claimFilesTool, claimTaskTool, releaseTool, listClaimsTool, subscribeTool, unsubscribeTool, pollTool, ];