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 type { ProviderDefinition } from "./provider-registry.js";
|
||||||
import { AgentListItemPayloadSchema, AgentSnapshotPayloadSchema } from "../../shared/messages.js";
|
import { AgentListItemPayloadSchema, AgentSnapshotPayloadSchema } from "../../shared/messages.js";
|
||||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.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 { ScheduleService } from "../schedule/service.js";
|
||||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||||
import {
|
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", () => {
|
describe("provider listing MCP tool", () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ import type {
|
|||||||
CreatePaseoWorktreeWorkflowResult,
|
CreatePaseoWorktreeWorkflowResult,
|
||||||
} from "../worktree-session.js";
|
} from "../worktree-session.js";
|
||||||
import type { ScheduleService } from "../schedule/service.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 type { ProviderDefinition } from "./provider-registry.js";
|
||||||
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||||
import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.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: {
|
function resolveChildAgentCwd(params: {
|
||||||
parentCwd: string;
|
parentCwd: string;
|
||||||
requestedCwd?: 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(
|
server.registerTool(
|
||||||
"list_providers",
|
"list_providers",
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user