Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .dispatch/tools.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Expand Down
18 changes: 12 additions & 6 deletions src/mcp/repo-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -47,8 +48,9 @@ export async function loadRepoTools(repoRoot: string): Promise<RepoToolDefinitio

return rawTools.map((rawTool, index) => {
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;
Expand All @@ -67,7 +69,7 @@ export async function loadRepoTools(repoRoot: string): Promise<RepoToolDefinitio
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
message: result.stdout || `Ran ${tool.name} in ${currentRepoRoot}.`
message: result.stdout || `Ran ${prefixedName} in ${currentRepoRoot}.`
};
}
};
Expand All @@ -94,11 +96,15 @@ function parseRepoTool(value: unknown, index: number): RepoToolConfig {
? rawTool.command.map((part) => 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.`);
Expand Down
8 changes: 5 additions & 3 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }
Expand All @@ -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<void> {
const server = await createDispatchMcpServer(context);
const transport = new StreamableHTTPServerTransport({
Expand Down Expand Up @@ -352,8 +353,9 @@ async function createDispatchMcpServer(context: McpRequestContext): Promise<McpS
);
}

if (context.agent && context.repoRoot) {
const repoTools = await loadRepoTools(context.repoRoot);
const toolsRoot = context.worktreeRoot ?? context.repoRoot;
if (context.agent && toolsRoot) {
const repoTools = await loadRepoTools(toolsRoot);
for (const tool of repoTools) {
server.registerTool(
tool.name,
Expand Down
14 changes: 14 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,8 +526,10 @@ async function registerRoutes() {
}

let repoRoot: string | null = null;
let worktreeRoot: string | null = null;
try {
repoRoot = await resolveRepoRoot(agent.cwd);
worktreeRoot = await resolveWorktreeRoot(agent.cwd);
} catch {
// Agent may not be in a git repository — MCP still works, just without repo context.
}
Expand All @@ -536,6 +538,7 @@ async function registerRoutes() {
await handleMcpRequest(request.raw, reply.raw, request.body, {
agent,
repoRoot,
worktreeRoot,
upsertEvent: mcpUpsertEvent,
shareMedia: mcpShareMedia
});
Expand Down Expand Up @@ -1803,6 +1806,17 @@ async function resolveRepoRoot(cwd: string): Promise<string> {
);
}

async function resolveWorktreeRoot(cwd: string): Promise<string> {
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",
Expand Down
12 changes: 6 additions & 6 deletions test/repo-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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"]
}
Expand All @@ -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,
Expand All @@ -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"));
Expand All @@ -60,14 +60,14 @@ describe("loadRepoTools", () => {
JSON.stringify({
tools: [
{
name: "dev_up",
name: "project.dev_up",
description: "Start the repo dev stack.",
command: ["dispatch-dev", "up"]
}
]
})
);

await expect(loadRepoTools(repoRoot)).rejects.toThrow('must start with "project."');
await expect(loadRepoTools(repoRoot)).rejects.toThrow("must not contain dots");
});
});
Loading