From ddc68f435b6bee5d06e0620aa0065711318734d5 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 21 May 2026 22:05:50 -0600 Subject: [PATCH] Expose job & template CRUD as MCP tools for agents (#DIS-163) Add 11 MCP tools that let agents create, read, update, delete, and run jobs and templates directly. Tools are available to both regular agents and job agents, but excluded from persona/review agents. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/jobs/service.ts | 11 + apps/server/src/routes/mcp.ts | 51 +- apps/server/src/server.ts | 1 + apps/server/src/shared/mcp/crud-tools.ts | 624 +++++++++++++++++++++++ apps/server/src/shared/mcp/server.ts | 32 ++ apps/server/src/templates/service.ts | 7 + 6 files changed, 725 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/shared/mcp/crud-tools.ts diff --git a/apps/server/src/jobs/service.ts b/apps/server/src/jobs/service.ts index bdf66f66..08ceb93c 100644 --- a/apps/server/src/jobs/service.ts +++ b/apps/server/src/jobs/service.ts @@ -116,6 +116,17 @@ export class JobService { } } + async getJobById(jobId: string): Promise { + return this.store.getJob(jobId); + } + + async getJobByName( + directory: string, + name: string + ): Promise { + return this.store.getJobByDirectoryAndName(directory, name); + } + async runJob(input: RunJobInput): Promise { const job = await this.getJobOrThrow(input.directory, input.name); diff --git a/apps/server/src/routes/mcp.ts b/apps/server/src/routes/mcp.ts index ef448649..8a3f9522 100644 --- a/apps/server/src/routes/mcp.ts +++ b/apps/server/src/routes/mcp.ts @@ -1,12 +1,19 @@ +import path from "node:path"; + import type { FastifyInstance } from "fastify"; import type { AgentManager } from "../agents/manager.js"; import type { BrainStore } from "../brain/store.js"; -import type { JobService } from "../jobs/service.js"; +import type { AddJobInput, JobService } from "../jobs/service.js"; +import type { + TemplateService, + AddTemplateInput, +} from "../templates/service.js"; import { resolveRepoRoot, resolveWorktreeRoot, } from "../shared/git/git-context.js"; +import type { CrudToolCallbacks } from "../shared/mcp/crud-tools.js"; import { handleMcpRequest } from "../shared/mcp/server.js"; type McpRouteDeps = { @@ -15,6 +22,7 @@ type McpRouteDeps = { }; agentManager: AgentManager; jobService: JobService; + templateService: TemplateService; brainStore: BrainStore; publishBrainChanged: (repoRoot: string) => void; getBearerToken: (request: { @@ -56,6 +64,45 @@ type McpRouteDeps = { mcpMethodNotAllowed: () => unknown; }; +function buildCrudCallbacks(deps: McpRouteDeps): CrudToolCallbacks { + return { + listJobs: async (directory) => { + const jobs = await deps.jobService.listJobs(); + if (!directory) return jobs; + const resolved = path.resolve(directory); + return jobs.filter((j) => j.directory === resolved); + }, + getJobById: (jobId) => deps.jobService.getJobById(jobId), + getJobByName: (directory, name) => + deps.jobService.getJobByName(directory, name), + createJob: (input) => deps.jobService.addJob(input as AddJobInput), + updateJob: (input) => deps.jobService.updateJob(input as AddJobInput), + deleteJob: (name, directory) => + deps.jobService.removeJob({ name, directory }), + runJob: (name, directory) => + deps.jobService.runJob({ name, directory, wait: false }), + listTemplates: async (directory) => { + const templates = await deps.templateService.listTemplates(); + if (!directory) return templates; + const resolved = path.resolve(directory); + return templates.filter((t) => t.directory === resolved); + }, + getTemplateById: (templateId) => + deps.templateService.getTemplate(templateId), + getTemplateByName: (directory, name) => + deps.templateService.getTemplateByName(directory, name), + createTemplate: (input) => + deps.templateService.addTemplate(input as AddTemplateInput), + updateTemplate: (templateId, input) => + deps.templateService.updateTemplate( + templateId, + input as Partial + ), + deleteTemplate: (templateId) => + deps.templateService.removeTemplate(templateId), + }; +} + export async function registerMcpRoutes( app: FastifyInstance, deps: McpRouteDeps @@ -171,6 +218,7 @@ export async function registerMcpRoutes( >, toolScope: run ? "job" : "agent", jobTools, + crudTools: buildCrudCallbacks(deps), brainStore: deps.brainStore, publishBrainChanged: deps.publishBrainChanged, } as Parameters[3]); @@ -255,6 +303,7 @@ export async function registerMcpRoutes( deps.agentManager.getFeedbackSummary(params as never) as Promise< Record >, + crudTools: buildCrudCallbacks(deps), brainStore: deps.brainStore, publishBrainChanged: deps.publishBrainChanged, } as Parameters[3]); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 221a756b..ec54d9b8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -442,6 +442,7 @@ async function registerRoutes() { config, agentManager, jobService, + templateService, brainStore, publishBrainChanged: (repoRoot: string) => uiEventBroker.publish({ type: "brain.changed", repoRoot }), diff --git a/apps/server/src/shared/mcp/crud-tools.ts b/apps/server/src/shared/mcp/crud-tools.ts new file mode 100644 index 00000000..796e9f38 --- /dev/null +++ b/apps/server/src/shared/mcp/crud-tools.ts @@ -0,0 +1,624 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; + +import { toToolError } from "./tool-error.js"; + +const jobAgentTypeEnum = z.enum(["claude", "codex", "opencode", "cursor"]); +const templateAgentTypeEnum = z.enum([ + "claude", + "codex", + "opencode", + "cursor", + "terminal", +]); + +export type CrudToolCallbacks = { + listJobs: (directory?: string) => Promise; + getJobById: (jobId: string) => Promise; + getJobByName: (directory: string, name: string) => Promise; + createJob: (input: { + name: string; + directory: string; + prompt?: string | null; + schedule?: string | null; + timeoutMs?: number; + needsInputTimeoutMs?: number; + agentType?: string; + useWorktree?: boolean; + baseBranch?: string | null; + branchName?: string | null; + fullAccess?: boolean; + autoArchive?: boolean; + callable?: boolean; + singleton?: boolean; + enabled?: boolean; + }) => Promise; + updateJob: (input: { + name: string; + directory: string; + displayName?: string; + prompt?: string | null; + schedule?: string | null; + timeoutMs?: number; + needsInputTimeoutMs?: number; + agentType?: string; + useWorktree?: boolean; + baseBranch?: string | null; + branchName?: string | null; + fullAccess?: boolean; + autoArchive?: boolean; + callable?: boolean; + singleton?: boolean; + enabled?: boolean; + }) => Promise; + deleteJob: (name: string, directory: string) => Promise; + runJob: (name: string, directory: string) => Promise; + listTemplates: (directory?: string) => Promise; + getTemplateById: (templateId: string) => Promise; + getTemplateByName: (directory: string, name: string) => Promise; + createTemplate: (input: { + name: string; + directory: string; + description?: string | null; + prompt?: string | null; + agentType?: string; + useWorktree?: boolean; + baseBranch?: string | null; + branchName?: string | null; + fullAccess?: boolean; + callable?: boolean; + allowMedia?: boolean; + }) => Promise; + updateTemplate: ( + templateId: string, + input: { + name?: string; + directory?: string; + description?: string | null; + prompt?: string | null; + agentType?: string; + useWorktree?: boolean; + baseBranch?: string | null; + branchName?: string | null; + fullAccess?: boolean; + callable?: boolean; + allowMedia?: boolean; + } + ) => Promise; + deleteTemplate: (templateId: string) => Promise; +}; + +export function registerCrudTools( + server: McpServer, + allowed: Set, + opts: { + defaultCwd: string | undefined; + callbacks: CrudToolCallbacks; + } +): void { + const { defaultCwd, callbacks } = opts; + + function resolveDir(value: string | undefined): string { + const dir = value?.trim() || defaultCwd?.trim(); + if (!dir) throw new Error("directory is required."); + return dir; + } + + function dirSchema(): z.ZodType { + const desc = defaultCwd + ? `Working directory. Defaults to ${defaultCwd}.` + : "Working directory (required)."; + return defaultCwd + ? z.string().optional().describe(desc) + : z.string().describe(desc); + } + + // ── list_jobs ────────────────────────────────────────────────── + if (allowed.has("list_jobs")) { + server.registerTool( + "list_jobs", + { + description: + "List jobs, scoped to a directory. Defaults to the agent's working directory.", + inputSchema: { directory: dirSchema() }, + }, + async (args) => { + try { + const result = await callbacks.listJobs( + args.directory?.trim() || defaultCwd + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + // ── get_job ──────────────────────────────────────────────────── + if (allowed.has("get_job")) { + server.registerTool( + "get_job", + { + description: + "Get a single job by ID or name. When using name, directory defaults to the agent's working directory.", + inputSchema: { + jobId: z + .string() + .optional() + .describe("Job ID. Provide jobId or name, not both."), + name: z + .string() + .optional() + .describe("Job name. Used with directory for lookup."), + directory: dirSchema(), + }, + }, + async (args) => { + try { + if (!args.jobId && !args.name) { + return toToolError(new Error("Provide either jobId or name.")); + } + const result = args.jobId + ? await callbacks.getJobById(args.jobId) + : await callbacks.getJobByName( + resolveDir(args.directory), + args.name! + ); + if (!result) return toToolError(new Error("Job not found.")); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + // ── create_job ───────────────────────────────────────────────── + if (allowed.has("create_job")) { + server.registerTool( + "create_job", + { + description: + "Create a new job. Only name is required; everything else has sensible defaults.", + inputSchema: { + name: z.string().min(1).describe("Job name."), + directory: dirSchema(), + prompt: z.string().nullable().optional().describe("Job prompt."), + schedule: z + .string() + .nullable() + .optional() + .describe("Cron expression for scheduled runs."), + timeoutMs: z + .number() + .int() + .positive() + .optional() + .describe("Execution timeout in milliseconds."), + needsInputTimeoutMs: z + .number() + .int() + .positive() + .optional() + .describe("Timeout for human input in milliseconds."), + agentType: jobAgentTypeEnum + .default("claude") + .describe("Agent runtime type."), + useWorktree: z + .boolean() + .default(false) + .describe("Run in a git worktree."), + baseBranch: z + .string() + .nullable() + .optional() + .describe("Base branch for worktree."), + branchName: z + .string() + .nullable() + .optional() + .describe("Branch name for worktree."), + fullAccess: z + .boolean() + .default(false) + .describe("Grant full filesystem access."), + autoArchive: z + .boolean() + .default(true) + .describe("Auto-archive agent on completion."), + callable: z + .boolean() + .default(false) + .describe("Allow triggering via API."), + singleton: z + .boolean() + .default(true) + .describe("Only one active run at a time."), + enabled: z + .boolean() + .default(false) + .describe("Enable scheduled runs."), + }, + }, + async (args) => { + try { + const result = await callbacks.createJob({ + ...args, + directory: resolveDir(args.directory), + }); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + // ── update_job ───────────────────────────────────────────────── + if (allowed.has("update_job")) { + server.registerTool( + "update_job", + { + description: + "Update an existing job. Identifies the job by name (+ directory). Only pass fields you want to change.", + inputSchema: { + name: z + .string() + .min(1) + .describe("Current job name (identifies the job)."), + directory: dirSchema(), + displayName: z + .string() + .optional() + .describe("New display name (to rename the job)."), + prompt: z + .string() + .nullable() + .optional() + .describe("New prompt. Pass null to clear."), + schedule: z + .string() + .nullable() + .optional() + .describe("New cron schedule. Pass null to clear."), + timeoutMs: z + .number() + .int() + .positive() + .optional() + .describe("Execution timeout in milliseconds."), + needsInputTimeoutMs: z + .number() + .int() + .positive() + .optional() + .describe("Timeout for human input in milliseconds."), + agentType: jobAgentTypeEnum + .optional() + .describe("Agent runtime type."), + useWorktree: z + .boolean() + .optional() + .describe("Run in a git worktree."), + baseBranch: z + .string() + .nullable() + .optional() + .describe("Base branch for worktree."), + branchName: z + .string() + .nullable() + .optional() + .describe("Branch name for worktree."), + fullAccess: z + .boolean() + .optional() + .describe("Grant full filesystem access."), + autoArchive: z + .boolean() + .optional() + .describe("Auto-archive agent on completion."), + callable: z + .boolean() + .optional() + .describe("Allow triggering via API."), + singleton: z + .boolean() + .optional() + .describe("Only one active run at a time."), + enabled: z.boolean().optional().describe("Enable scheduled runs."), + }, + }, + async (args) => { + try { + const result = await callbacks.updateJob({ + ...args, + directory: resolveDir(args.directory), + }); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + // ── delete_job ───────────────────────────────────────────────── + if (allowed.has("delete_job")) { + server.registerTool( + "delete_job", + { + description: + "Delete a job by name. Fails if the job has an active run.", + inputSchema: { + name: z.string().min(1).describe("Job name."), + directory: dirSchema(), + }, + }, + async (args) => { + try { + const result = await callbacks.deleteJob( + args.name, + resolveDir(args.directory) + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + // ── run_job ──────────────────────────────────────────────────── + if (allowed.has("run_job")) { + server.registerTool( + "run_job", + { + description: + "Trigger a job run. Returns immediately with the run ID and agent ID.", + inputSchema: { + name: z.string().min(1).describe("Job name."), + directory: dirSchema(), + }, + }, + async (args) => { + try { + const result = await callbacks.runJob( + args.name, + resolveDir(args.directory) + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + // ── list_templates ───────────────────────────────────────────── + if (allowed.has("list_templates")) { + server.registerTool( + "list_templates", + { + description: + "List templates, scoped to a directory. Defaults to the agent's working directory.", + inputSchema: { directory: dirSchema() }, + }, + async (args) => { + try { + const result = await callbacks.listTemplates( + args.directory?.trim() || defaultCwd + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + // ── get_template ─────────────────────────────────────────────── + if (allowed.has("get_template")) { + server.registerTool( + "get_template", + { + description: + "Get a single template by ID or name. When using name, directory defaults to the agent's working directory.", + inputSchema: { + templateId: z + .string() + .optional() + .describe("Template ID. Provide templateId or name, not both."), + name: z + .string() + .optional() + .describe("Template name. Used with directory for lookup."), + directory: dirSchema(), + }, + }, + async (args) => { + try { + if (!args.templateId && !args.name) { + return toToolError(new Error("Provide either templateId or name.")); + } + const result = args.templateId + ? await callbacks.getTemplateById(args.templateId) + : await callbacks.getTemplateByName( + resolveDir(args.directory), + args.name! + ); + if (!result) return toToolError(new Error("Template not found.")); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + // ── create_template ──────────────────────────────────────────── + if (allowed.has("create_template")) { + server.registerTool( + "create_template", + { + description: + "Create a new template. Only name is required; everything else has sensible defaults.", + inputSchema: { + name: z.string().min(1).describe("Template name."), + directory: dirSchema(), + description: z + .string() + .nullable() + .optional() + .describe("Template description."), + prompt: z.string().nullable().optional().describe("Template prompt."), + agentType: templateAgentTypeEnum + .default("claude") + .describe("Agent runtime type."), + useWorktree: z + .boolean() + .default(false) + .describe("Run in a git worktree."), + baseBranch: z + .string() + .nullable() + .optional() + .describe("Base branch for worktree."), + branchName: z + .string() + .nullable() + .optional() + .describe("Branch name for worktree."), + fullAccess: z + .boolean() + .default(false) + .describe("Grant full filesystem access."), + callable: z + .boolean() + .default(true) + .describe("Show in Cmd+K launcher."), + allowMedia: z + .boolean() + .default(true) + .describe("Allow media attachments when launching."), + }, + }, + async (args) => { + try { + const result = await callbacks.createTemplate({ + ...args, + directory: resolveDir(args.directory), + }); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + // ── update_template ──────────────────────────────────────────── + if (allowed.has("update_template")) { + server.registerTool( + "update_template", + { + description: + "Update an existing template. Only pass fields you want to change.", + inputSchema: { + templateId: z.string().describe("Template ID."), + name: z.string().optional().describe("New name."), + directory: z.string().optional().describe("New directory."), + description: z + .string() + .nullable() + .optional() + .describe("New description. Pass null to clear."), + prompt: z + .string() + .nullable() + .optional() + .describe("New prompt. Pass null to clear."), + agentType: templateAgentTypeEnum + .optional() + .describe("Agent runtime type."), + useWorktree: z + .boolean() + .optional() + .describe("Run in a git worktree."), + baseBranch: z + .string() + .nullable() + .optional() + .describe("Base branch for worktree."), + branchName: z + .string() + .nullable() + .optional() + .describe("Branch name for worktree."), + fullAccess: z + .boolean() + .optional() + .describe("Grant full filesystem access."), + callable: z.boolean().optional().describe("Show in Cmd+K launcher."), + allowMedia: z + .boolean() + .optional() + .describe("Allow media attachments when launching."), + }, + }, + async (args) => { + try { + const { templateId, ...updates } = args; + const result = await callbacks.updateTemplate(templateId, updates); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + // ── delete_template ──────────────────────────────────────────── + if (allowed.has("delete_template")) { + server.registerTool( + "delete_template", + { + description: "Delete a template. Fails if any jobs reference it.", + inputSchema: { + templateId: z.string().describe("Template ID."), + }, + }, + async (args) => { + try { + const result = await callbacks.deleteTemplate(args.templateId); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } +} diff --git a/apps/server/src/shared/mcp/server.ts b/apps/server/src/shared/mcp/server.ts index 7145f330..85b60d84 100644 --- a/apps/server/src/shared/mcp/server.ts +++ b/apps/server/src/shared/mcp/server.ts @@ -8,6 +8,7 @@ import type { BrainStore } from "../../brain/store.js"; import { createPr, getPrStatus } from "../github/pr.js"; import { registerAnalyticsTools } from "./analytics-tools.js"; import { registerBrainTools } from "./brain-tools.js"; +import { registerCrudTools, type CrudToolCallbacks } from "./crud-tools.js"; import { registerJobTools, type JobTools } from "./job-tools.js"; import { registerPersonaInteractionTools, @@ -104,6 +105,17 @@ const AGENT_TOOLS = new Set([ "brain_list_delete", "brain_append_event", "brain_query_events", + "list_jobs", + "get_job", + "create_job", + "update_job", + "delete_job", + "run_job", + "list_templates", + "get_template", + "create_template", + "update_template", + "delete_template", ]); const JOB_TOOLS = new Set([ @@ -142,6 +154,17 @@ const JOB_TOOLS = new Set([ "brain_list_delete", "brain_append_event", "brain_query_events", + "list_jobs", + "get_job", + "create_job", + "update_job", + "delete_job", + "run_job", + "list_templates", + "get_template", + "create_template", + "update_template", + "delete_template", ]); const PERSONA_TOOLS = new Set([ @@ -353,6 +376,7 @@ export type McpRequestContext = { groupBy: "persona" | "severity" | "directory"; }) => Promise>; jobTools?: JobTools; + crudTools?: CrudToolCallbacks; toolScope?: "agent" | "reviewer" | "job"; brainStore?: BrainStore; publishBrainChanged?: (repoRoot: string) => void; @@ -734,6 +758,14 @@ async function createDispatchMcpServer( context.getFeedbackSummary ?? context.jobTools?.getFeedbackSummary, }); + // ── Job & template CRUD tools ───────────────────────────────────── + if (context.crudTools) { + registerCrudTools(server, allowed, { + defaultCwd, + callbacks: context.crudTools, + }); + } + // ── Job tools ────────────────────────────────────────────────────── if (allowed.has("job_complete") && context.agent && context.jobTools) { registerJobTools(server, context.agent.id, context.jobTools); diff --git a/apps/server/src/templates/service.ts b/apps/server/src/templates/service.ts index 0ee1211e..e1bb52c0 100644 --- a/apps/server/src/templates/service.ts +++ b/apps/server/src/templates/service.ts @@ -132,6 +132,13 @@ export class TemplateService { return await this.store.getTemplate(id); } + async getTemplateByName( + directory: string, + name: string + ): Promise { + return await this.store.getTemplateByDirectoryAndName(directory, name); + } + async launchTemplate(input: LaunchTemplateInput): Promise { const template = await this.store.getTemplate(input.templateId); if (!template) {