mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user