From be44e9acc0f2ef5e49afc03afefefdd27f40056e Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sun, 15 Mar 2026 21:42:20 -0600 Subject: [PATCH] Auto-prefix repo tools with "repo." instead of requiring "project." prefix Repos no longer need to manually prefix tool names with "project." in .dispatch/tools.json. Dispatch now automatically adds a "repo." prefix when surfacing tools to agents (e.g. "dev_up" becomes "repo.dev_up"). Also fixes repo tool loading in worktrees by resolving the worktree root via --show-toplevel instead of --git-common-dir, so .dispatch/tools.json is read from the correct checkout. Co-Authored-By: Claude Opus 4.6 (1M context) --- .dispatch/tools.json | 8 ++++---- src/mcp/repo-tools.ts | 18 ++++++++++++------ src/mcp/server.ts | 8 +++++--- src/server.ts | 14 ++++++++++++++ test/repo-tools.test.ts | 12 ++++++------ 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/.dispatch/tools.json b/.dispatch/tools.json index 5a450e71..1e6b638c 100644 --- a/.dispatch/tools.json +++ b/.dispatch/tools.json @@ -1,22 +1,22 @@ { "tools": [ { - "name": "project.dev_up", + "name": "dev_up", "description": "Start the repo's isolated dev environment (DB + API server + Vite frontend on free ports).", "command": ["./bin/dispatch-dev", "up"] }, { - "name": "project.dev_down", + "name": "dev_down", "description": "Stop the repo's dev environment and remove its database container.", "command": ["./bin/dispatch-dev", "down"] }, { - "name": "project.dev_status", + "name": "dev_status", "description": "Show which dev services are running and their ports.", "command": ["./bin/dispatch-dev", "status"] }, { - "name": "project.dev_logs", + "name": "dev_logs", "description": "Show recent API server logs from the dev environment.", "command": ["./bin/dispatch-dev", "logs"] } diff --git a/src/mcp/repo-tools.ts b/src/mcp/repo-tools.ts index dfa30bee..e4d6f453 100644 --- a/src/mcp/repo-tools.ts +++ b/src/mcp/repo-tools.ts @@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises"; import { runCommand } from "../lib/run-command.js"; const REPO_TOOL_MANIFEST_PATH = path.join(".dispatch", "tools.json"); +const REPO_TOOL_PREFIX = "repo."; const BUILTIN_TOOL_NAMES = new Set([ "create_worktree", "cleanup_worktree", @@ -47,8 +48,9 @@ export async function loadRepoTools(repoRoot: string): Promise { const tool = parseRepoTool(rawTool, index); + const prefixedName = `${REPO_TOOL_PREFIX}${tool.name}`; return { - name: tool.name, + name: prefixedName, description: tool.description, run: async ({ agentId, repoRoot: currentRepoRoot }) => { const [command, ...args] = tool.command; @@ -67,7 +69,7 @@ export async function loadRepoTools(repoRoot: string): Promise part.trim()).filter(Boolean) : []; - if (!name.startsWith("project.")) { - throw new Error(`Repo tool "${name || `index ${index}`}" must start with "project.".`); + if (!name) { + throw new Error(`Repo tool at index ${index} must have a non-empty name.`); } - if (BUILTIN_TOOL_NAMES.has(name)) { - throw new Error(`Repo tool "${name}" collides with a built-in Dispatch MCP tool.`); + if (name.includes(".")) { + throw new Error(`Repo tool "${name}" must not contain dots. Dispatch automatically prefixes repo tools with "${REPO_TOOL_PREFIX}".`); + } + const prefixedName = `${REPO_TOOL_PREFIX}${name}`; + if (BUILTIN_TOOL_NAMES.has(prefixedName)) { + throw new Error(`Repo tool "${name}" collides with a built-in Dispatch MCP tool when prefixed as "${prefixedName}".`); } if (!description) { throw new Error(`Repo tool "${name}" must include a description.`); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2b7ad1d4..02d7a99e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -30,6 +30,7 @@ export type MediaResult = { export type McpRequestContext = { agent: AgentRecord | null; repoRoot: string | null; + worktreeRoot: string | null; upsertEvent?: ( agentId: string, event: { type: string; message: string; metadata?: Record } @@ -44,7 +45,7 @@ export async function handleMcpRequest( req: IncomingMessage, res: ServerResponse, parsedBody?: unknown, - context: McpRequestContext = { agent: null, repoRoot: null } + context: McpRequestContext = { agent: null, repoRoot: null, worktreeRoot: null } ): Promise { const server = await createDispatchMcpServer(context); const transport = new StreamableHTTPServerTransport({ @@ -352,8 +353,9 @@ async function createDispatchMcpServer(context: McpRequestContext): Promise { ); } +async function resolveWorktreeRoot(cwd: string): Promise { + return normalizePath( + ( + await runCommand("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { + allowedExitCodes: [0], + timeoutMs: PROBE_COMMAND_TIMEOUT_MS + }) + ).stdout + ); +} + function mcpMethodNotAllowed(): { jsonrpc: "2.0"; error: { code: number; message: string }; id: null } { return { jsonrpc: "2.0", diff --git a/test/repo-tools.test.ts b/test/repo-tools.test.ts index c1bf91d8..4bf0974d 100644 --- a/test/repo-tools.test.ts +++ b/test/repo-tools.test.ts @@ -20,7 +20,7 @@ afterEach(async () => { }); describe("loadRepoTools", () => { - it("loads project-scoped repo tools from .dispatch/tools.json", async () => { + it("auto-prefixes repo tools with repo. namespace", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "dispatch-repo-tools-")); tempDirs.push(repoRoot); await mkdir(path.join(repoRoot, ".dispatch")); @@ -29,7 +29,7 @@ describe("loadRepoTools", () => { JSON.stringify({ tools: [ { - name: "project.dev_up", + name: "dev_up", description: "Start the repo dev stack.", command: ["dispatch-dev", "up"] } @@ -40,7 +40,7 @@ describe("loadRepoTools", () => { const [tool] = await loadRepoTools(repoRoot); const result = await tool.run({ agentId: "agt_test", repoRoot }); - expect(tool.name).toBe("project.dev_up"); + expect(tool.name).toBe("repo.dev_up"); expect(result.agentId).toBe("agt_test"); expect(vi.mocked(runCommand)).toHaveBeenCalledWith("dispatch-dev", ["up"], expect.objectContaining({ cwd: repoRoot, @@ -51,7 +51,7 @@ describe("loadRepoTools", () => { })); }); - it("rejects repo tools that do not use the project namespace", async () => { + it("rejects repo tools whose names contain dots", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "dispatch-repo-tools-")); tempDirs.push(repoRoot); await mkdir(path.join(repoRoot, ".dispatch")); @@ -60,7 +60,7 @@ describe("loadRepoTools", () => { JSON.stringify({ tools: [ { - name: "dev_up", + name: "project.dev_up", description: "Start the repo dev stack.", command: ["dispatch-dev", "up"] } @@ -68,6 +68,6 @@ describe("loadRepoTools", () => { }) ); - await expect(loadRepoTools(repoRoot)).rejects.toThrow('must start with "project."'); + await expect(loadRepoTools(repoRoot)).rejects.toThrow("must not contain dots"); }); });