From ec646db844db331374909896f76844f95a207029 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 16 May 2026 09:45:22 -0500 Subject: [PATCH] fix(mcp): add missing schedule tools (update, logs, run-once) (#1032) * fix(mcp): add missing schedule tools (update, logs, run-once) Add three schedule MCP tools to close the parity gap between CLI (`paseo schedule update/logs/run-once`) and MCP surface. The WebSocket RPC handlers and ScheduleService methods already exist (added in PR #694), but the MCP tools were never registered. Tools added: - update_schedule: update interval, prompt, name, maxRuns, etc. - schedule_logs: retrieve run history for a schedule - run_once_schedule: trigger immediate one-time execution Includes 8 unit tests covering all three tools. Fixes #1031 * Fix schedule MCP update handling --------- Co-authored-by: Mohamed Boudra --- .../src/server/agent/mcp-server.test.ts | 244 +++++++++++++++++- .../server/src/server/agent/mcp-server.ts | 201 ++++++++++++++- 2 files changed, 443 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index f857143f2..ab27332e5 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -13,7 +13,11 @@ import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { ProviderDefinition } from "./provider-registry.js"; import { AgentListItemPayloadSchema, AgentSnapshotPayloadSchema } from "../../shared/messages.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js"; -import type { CreateScheduleInput, StoredSchedule } from "../schedule/types.js"; +import type { + CreateScheduleInput, + StoredSchedule, + UpdateScheduleInput, +} from "../schedule/types.js"; import type { ScheduleService } from "../schedule/service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import { @@ -1672,6 +1676,244 @@ describe("create_schedule MCP tool", () => { }); }); +describe("update_schedule MCP tool", () => { + const logger = createTestLogger(); + + function makeStoredSchedule(): StoredSchedule { + return { + id: "schedule-1", + name: "test schedule", + prompt: "say hello", + cadence: { type: "every", everyMs: 300000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: "/tmp" } }, + status: "active", + createdAt: "2026-04-11T00:00:00.000Z", + updatedAt: "2026-04-11T00:00:00.000Z", + nextRunAt: "2026-04-11T00:05:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }; + } + + it("calls scheduleService.update with correct input", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const stored = makeStoredSchedule(); + const update = vi.fn(async (_input: UpdateScheduleInput) => ({ + ...stored, + name: "updated name", + prompt: "new prompt", + updatedAt: "2026-04-11T01:00:00.000Z", + })); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + scheduleService: { update } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "update_schedule"); + + await tool.handler({ + id: "schedule-1", + name: "updated name", + prompt: "new prompt", + }); + + expect(update).toHaveBeenCalledWith({ + id: "schedule-1", + name: "updated name", + prompt: "new prompt", + }); + }); + + it("converts every to cadence", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const stored = makeStoredSchedule(); + const update = vi.fn(async (_input: UpdateScheduleInput) => stored); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + scheduleService: { update } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "update_schedule"); + + await tool.handler({ + id: "schedule-1", + every: "10m", + }); + + expect(update).toHaveBeenCalledWith({ + id: "schedule-1", + cadence: { type: "every", everyMs: 600000 }, + }); + }); + + it("rejects both every and cron", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const update = vi.fn(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + scheduleService: { update } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "update_schedule"); + + await expect( + tool.handler({ + id: "schedule-1", + every: "5m", + cron: "* * * * *", + }), + ).rejects.toThrow("Specify at most one of every or cron"); + expect(update).not.toHaveBeenCalled(); + }); + + it("passes new-agent config and expiry updates", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const stored = makeStoredSchedule(); + const update = vi.fn(async (_input: UpdateScheduleInput) => stored); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + scheduleService: { update } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "update_schedule"); + + await tool.handler({ + id: "schedule-1", + provider: "codex/gpt-5.4", + mode: "full-access", + cwd: "/home/user/project", + expiresIn: "1h", + }); + + const updateInput = update.mock.calls[0]?.[0]; + expect(updateInput).toMatchObject({ + id: "schedule-1", + newAgentConfig: { + provider: "codex", + model: "gpt-5.4", + modeId: "full-access", + cwd: "/home/user/project", + }, + }); + expect(updateInput?.expiresAt).toEqual(expect.any(String)); + }); + + it("clears model, mode, max runs, and expiry", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const stored = makeStoredSchedule(); + const update = vi.fn(async (_input: UpdateScheduleInput) => stored); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + scheduleService: { update } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "update_schedule"); + + await tool.handler({ + id: "schedule-1", + model: null, + mode: null, + maxRuns: null, + clearExpires: true, + }); + + expect(update).toHaveBeenCalledWith({ + id: "schedule-1", + maxRuns: null, + expiresAt: null, + newAgentConfig: { + model: null, + modeId: null, + }, + }); + }); + + it("rejects conflicting model and expiry inputs", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const update = vi.fn(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + scheduleService: { update } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "update_schedule"); + + await expect( + tool.handler({ + id: "schedule-1", + provider: "codex/gpt-5.4", + model: "gpt-5.5", + }), + ).rejects.toThrow("Conflicting model values provided"); + await expect( + tool.handler({ + id: "schedule-1", + expiresIn: "1h", + clearExpires: true, + }), + ).rejects.toThrow("Specify at most one of expiresIn or clearExpires"); + expect(update).not.toHaveBeenCalled(); + }); +}); + +describe("schedule_logs MCP tool", () => { + const logger = createTestLogger(); + + function makeRun(overrides: Partial<{ id: string; status: string }> = {}) { + return { + id: overrides.id ?? "run-1", + scheduledFor: "2026-04-11T00:00:00.000Z", + startedAt: "2026-04-11T00:00:01.000Z", + endedAt: "2026-04-11T00:00:05.000Z", + status: overrides.status ?? "succeeded", + agentId: null, + output: "done", + error: null, + }; + } + + it("returns runs for a schedule", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const runs = [makeRun({ id: "run-1" }), makeRun({ id: "run-2", status: "failed" })]; + const logs = vi.fn(async (_id: string) => runs); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + scheduleService: { logs } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "schedule_logs"); + + const result = await tool.handler({ id: "schedule-1" }); + + expect(logs).toHaveBeenCalledWith("schedule-1"); + expect(result.structuredContent).toEqual({ runs }); + }); + + it("throws when schedule service is not configured", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + }); + const tool = registeredTool(server, "schedule_logs"); + + await expect(tool.handler({ id: "schedule-1" })).rejects.toThrow( + "Schedule service is not configured", + ); + }); +}); + describe("provider listing MCP tool", () => { const logger = createTestLogger(); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 28a55a02c..89387363d 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -48,7 +48,12 @@ import type { CreatePaseoWorktreeWorkflowResult, } from "../worktree-session.js"; import type { ScheduleService } from "../schedule/service.js"; -import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; +import { + ScheduleRunSchema, + ScheduleSummarySchema, + StoredScheduleSchema, +} from "../schedule/types.js"; +import type { ScheduleCadence, UpdateScheduleInput } from "../schedule/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; import { getAgentProviderDefinition } from "./provider-manifest.js"; import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js"; @@ -256,6 +261,113 @@ function resolveScheduleProviderAndModel(params: { }; } +function resolveScheduleUpdateProviderAndModel(params: { + provider?: string; + model?: string | null; +}): { provider?: string; model?: string | null } { + const providerInput = params.provider?.trim(); + const modelInput = typeof params.model === "string" ? params.model.trim() : params.model; + + if (params.model !== undefined && modelInput === "") { + throw new Error("model cannot be empty"); + } + + if (!providerInput) { + return params.model !== undefined ? { model: modelInput } : {}; + } + + const slashIndex = providerInput.indexOf("/"); + if (slashIndex === -1) { + return { + provider: providerInput, + ...(params.model !== undefined ? { model: modelInput } : {}), + }; + } + + const provider = providerInput.slice(0, slashIndex).trim(); + const modelFromProvider = providerInput.slice(slashIndex + 1).trim(); + if (!provider || !modelFromProvider) { + throw new Error("provider must be or /"); + } + if (params.model === null) { + throw new Error("provider specifies a model but model is null"); + } + if (typeof modelInput === "string" && modelInput !== modelFromProvider) { + throw new Error("Conflicting model values provided"); + } + + return { + provider, + model: modelInput ?? modelFromProvider, + }; +} + +interface ScheduleUpdateToolInput { + id: string; + every?: string; + cron?: string; + name?: string | null; + prompt?: string; + maxRuns?: number | null; + provider?: string; + model?: string | null; + mode?: string | null; + cwd?: string; + expiresIn?: string; + clearExpires?: boolean; +} + +function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined { + if (input.every !== undefined && input.cron !== undefined) { + throw new Error("Specify at most one of every or cron"); + } + if (input.every !== undefined) { + return { type: "every", everyMs: parseDurationString(input.every) }; + } + if (input.cron !== undefined) { + return { type: "cron", expression: input.cron.trim() }; + } + return undefined; +} + +function resolveScheduleUpdateExpiresAt(input: ScheduleUpdateToolInput): string | null | undefined { + if (input.expiresIn !== undefined && input.clearExpires) { + throw new Error("Specify at most one of expiresIn or clearExpires"); + } + if (input.expiresIn !== undefined) { + return new Date(Date.now() + parseDurationString(input.expiresIn)).toISOString(); + } + if (input.clearExpires) { + return null; + } + return undefined; +} + +function buildScheduleUpdateInput(input: ScheduleUpdateToolInput): UpdateScheduleInput { + const cadence = resolveScheduleUpdateCadence(input); + const expiresAt = resolveScheduleUpdateExpiresAt(input); + const providerModelPatch = resolveScheduleUpdateProviderAndModel({ + provider: input.provider, + model: input.model, + }); + const newAgentConfig = { + ...(providerModelPatch.provider !== undefined ? { provider: providerModelPatch.provider } : {}), + ...(providerModelPatch.model !== undefined ? { model: providerModelPatch.model } : {}), + ...(input.mode !== undefined ? { modeId: input.mode } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + }; + + return { + id: input.id, + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.prompt !== undefined ? { prompt: input.prompt } : {}), + ...(cadence !== undefined ? { cadence } : {}), + ...(input.maxRuns !== undefined ? { maxRuns: input.maxRuns } : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(Object.keys(newAgentConfig).length > 0 ? { newAgentConfig } : {}), + }; +} + function resolveChildAgentCwd(params: { parentCwd: string; requestedCwd?: string; @@ -1762,6 +1874,93 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); + server.registerTool( + "update_schedule", + { + title: "Update schedule", + description: + "Update an existing schedule. Only provided fields are changed; omitted fields remain unchanged.", + inputSchema: { + id: z.string(), + every: z.string().optional().describe("New interval duration string (e.g. 5m, 1h)."), + cron: z.string().optional().describe("New cron expression."), + name: z.string().nullable().optional().describe("New name (null to clear)."), + prompt: z.string().trim().min(1).optional().describe("New prompt text."), + maxRuns: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe("New max runs limit (null to clear)."), + provider: z + .string() + .trim() + .min(1) + .optional() + .describe("New provider for new-agent target."), + model: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("New model for new-agent target (null to clear)."), + mode: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("New mode for new-agent target (null to clear)."), + cwd: z.string().trim().min(1).optional().describe("New cwd for new-agent target."), + expiresIn: z + .string() + .optional() + .describe("New relative expiry duration (for example: 1h, 2d)."), + clearExpires: z.boolean().optional().describe("Clear any schedule expiry."), + }, + outputSchema: StoredScheduleSchema.shape, + }, + async (input) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const schedule = await scheduleService.update(buildScheduleUpdateInput(input)); + + return { + content: [], + structuredContent: ensureValidJson(schedule), + }; + }, + ); + + server.registerTool( + "schedule_logs", + { + title: "Schedule logs", + description: "Get the run history (logs) for a schedule.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + runs: z.array(ScheduleRunSchema), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const runs = await scheduleService.logs(id); + return { + content: [], + structuredContent: ensureValidJson({ runs }), + }; + }, + ); + server.registerTool( "list_providers", {