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) {