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 <boudra.moha@gmail.com>
This commit is contained in:
Samuel K
2026-05-16 09:45:22 -05:00
committed by GitHub
parent 1812b14898
commit ec646db844
2 changed files with 443 additions and 2 deletions

View File

@@ -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();

View File

@@ -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 <provider> or <provider>/<model>");
}
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",
{