From cff41d78abd446a33d47a12e4b0d04b6b95ee6b0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 11 Apr 2026 12:46:42 +0700 Subject: [PATCH] Consolidate MCP server: remove voice bridge, add schedule/terminal/worktree tools Merge agent-management and agent MCP into a unified server with shared utilities. Remove the voice-mcp-bridge in favor of direct MCP tool registration. Add schedule, terminal, and worktree management tools to the MCP server for CLI parity. Include e2e parity tests. --- packages/cli/src/commands/daemon/index.ts | 1 + .../cli/src/commands/daemon/local-daemon.ts | 4 + packages/cli/src/commands/daemon/restart.ts | 1 + packages/cli/src/commands/daemon/start.ts | 1 + packages/server/scripts/test-mcp-inject.ts | 197 ++++ .../src/server/agent/agent-management-mcp.ts | 515 +++++++--- .../src/server/agent/agent-manager.test.ts | 94 ++ .../server/src/server/agent/agent-manager.ts | 23 +- .../src/server/agent/agent-mcp.e2e.test.ts | 118 +++ .../src/server/agent/agent-projections.ts | 9 +- .../src/server/agent/mcp-parity.e2e.test.ts | 665 +++++++++++++ .../src/server/agent/mcp-server.test.ts | 131 ++- .../server/src/server/agent/mcp-server.ts | 931 +++++++++++++++--- .../server/src/server/agent/mcp-shared.ts | 238 +++++ packages/server/src/server/config.ts | 6 +- packages/server/src/server/index.ts | 3 + .../server/src/server/persisted-config.ts | 3 +- packages/server/src/server/session.ts | 84 +- .../session.workspace-git-watch.test.ts | 4 +- .../src/server/session.workspaces.test.ts | 24 +- .../server/voice-mcp-bridge-command.test.ts | 51 - .../src/server/voice-mcp-bridge-command.ts | 50 - .../src/server/voice-mcp-bridge.test.ts | 88 -- .../server/src/server/voice-mcp-bridge.ts | 145 --- packages/server/src/server/voice-types.ts | 6 - .../websocket-server.notifications.test.ts | 3 +- .../websocket-server.relay-reconnect.test.ts | 3 +- .../server/src/server/websocket-server.ts | 26 +- 28 files changed, 2647 insertions(+), 777 deletions(-) create mode 100644 packages/server/scripts/test-mcp-inject.ts create mode 100644 packages/server/src/server/agent/mcp-parity.e2e.test.ts create mode 100644 packages/server/src/server/agent/mcp-shared.ts delete mode 100644 packages/server/src/server/voice-mcp-bridge-command.test.ts delete mode 100644 packages/server/src/server/voice-mcp-bridge-command.ts delete mode 100644 packages/server/src/server/voice-mcp-bridge.test.ts delete mode 100644 packages/server/src/server/voice-mcp-bridge.ts diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 7643bc549..5cb4f5b80 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -34,6 +34,7 @@ export function createDaemonCommand(): Command { .option("--port ", "Port for restarted daemon listen target") .option("--no-relay", "Disable relay on restarted daemon") .option("--no-mcp", "Disable Agent MCP on restarted daemon") + .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") .option( "--allowed-hosts ", 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 957ca64ce..142df7a8c 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -12,6 +12,7 @@ export interface DaemonStartOptions { foreground?: boolean; relay?: boolean; mcp?: boolean; + injectMcp?: boolean; allowedHosts?: string; } @@ -95,6 +96,9 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] { if (options.mcp === false) { args.push("--no-mcp"); } + if (options.injectMcp === false) { + args.push("--no-inject-mcp"); + } return args; } diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index 18b07fbed..82df597f5 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -60,6 +60,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions { port: typeof options.port === "string" ? options.port : undefined, relay: typeof options.relay === "boolean" ? options.relay : undefined, mcp: typeof options.mcp === "boolean" ? options.mcp : undefined, + injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined, allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined, }; diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 5c0111f13..1e5cd2264 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -18,6 +18,7 @@ export function startCommand(): Command { .option("--foreground", "Run in foreground (don't daemonize)") .option("--no-relay", "Disable relay connection") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") + .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") .option( "--allowed-hosts ", 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', diff --git a/packages/server/scripts/test-mcp-inject.ts b/packages/server/scripts/test-mcp-inject.ts new file mode 100644 index 000000000..55d8d7c5f --- /dev/null +++ b/packages/server/scripts/test-mcp-inject.ts @@ -0,0 +1,197 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import pino from "pino"; + +import { ClaudeAgentClient } from "../src/server/agent/providers/claude-agent.js"; +import { CodexAppServerAgentClient } from "../src/server/agent/providers/codex-app-server-agent.js"; +import { getFullAccessConfig, isProviderAvailable } from "../src/server/daemon-e2e/agent-configs.js"; +import { DaemonClient } from "../src/server/test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js"; + +function collectAssistantText( + entries: Array<{ item: { type: string; text?: string } }>, +): string { + return entries + .filter( + ( + entry, + ): entry is { item: { type: "assistant_message"; text: string } } => + entry.item.type === "assistant_message" && typeof entry.item.text === "string", + ) + .map((entry) => entry.item.text) + .join("\n"); +} + +type ToolCallRecord = { + name: string; + status: string; +}; + +type ProviderRunResult = { + provider: "claude" | "codex"; + agentId: string; + assistantText: string; + toolCalls: ToolCallRecord[]; +}; + +async function verifyInjectedMcpForProvider( + client: DaemonClient, + provider: "claude" | "codex", + cwd: string, +): Promise { + const created = await client.createAgent({ + cwd, + title: `mcp-inject-real-${provider}`, + ...getFullAccessConfig(provider), + }); + const agentId = created.id; + + try { + const prompt = [ + "List all your available MCP tools.", + "If you have a tool called list_agents or create_agent from a paseo MCP server, call list_agents once.", + "After checking, reply with exactly PASEO_MCP_FOUND.", + "If you do not have those tools, reply with exactly PASEO_MCP_NOT_FOUND.", + "Do not say anything else.", + ].join(" "); + + await client.sendMessage(agentId, prompt); + + const finished = await client.waitForFinish(agentId, 240_000); + if (finished.status !== "idle") { + throw new Error(`Agent did not finish successfully (status=${finished.status})`); + } + + const timeline = await client.fetchAgentTimeline(agentId, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const assistantText = collectAssistantText(timeline.entries); + const toolCalls = timeline.entries + .filter( + ( + entry, + ): entry is typeof entry & { + item: { type: "tool_call"; name: string; status: string }; + } => entry.item.type === "tool_call" && typeof entry.item.name === "string", + ) + .map((entry) => ({ + name: entry.item.name, + status: entry.item.status, + })); + + if (!assistantText.includes("PASEO_MCP_FOUND")) { + throw new Error( + `Expected assistant to confirm Paseo MCP availability. Assistant text:\n${assistantText}`, + ); + } + + const listAgentsCalls = toolCalls.filter( + (call) => + call.name === "list_agents" || + call.name === "paseo.list_agents" || + call.name.endsWith("__list_agents"), + ); + if (listAgentsCalls.length === 0) { + throw new Error( + `Expected agent to call list_agents. Tool calls:\n${JSON.stringify(toolCalls, null, 2)}`, + ); + } + if (!listAgentsCalls.some((call) => call.status === "completed")) { + throw new Error( + `Expected list_agents to complete successfully. Tool calls:\n${JSON.stringify(toolCalls, null, 2)}`, + ); + } + if (listAgentsCalls.some((call) => call.status === "failed")) { + throw new Error( + `Expected list_agents to succeed. Tool calls:\n${JSON.stringify(toolCalls, null, 2)}`, + ); + } + + return { + provider, + agentId, + assistantText, + toolCalls, + }; + } catch (error) { + await client.archiveAgent(agentId).catch(() => undefined); + throw error; + } +} + +async function main(): Promise { + if (!isProviderAvailable("claude")) { + throw new Error( + "Claude is not available in this environment. Ensure the `claude` binary and credentials are configured.", + ); + } + + const logger = pino({ level: "silent" }); + const rootCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-mcp-inject-real-")); + const claudeCwd = path.join(rootCwd, "claude"); + const codexCwd = path.join(rootCwd, "codex"); + const daemon = await createTestPaseoDaemon({ + agentClients: { + claude: new ClaudeAgentClient({ logger }), + ...(isProviderAvailable("codex") + ? { codex: new CodexAppServerAgentClient(logger) } + : {}), + }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const createdAgentIds: string[] = []; + + try { + await mkdir(claudeCwd, { recursive: true }); + await mkdir(codexCwd, { recursive: true }); + + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "mcp-inject-real-claude" }, + }); + + const results: ProviderRunResult[] = []; + + const claudeResult = await verifyInjectedMcpForProvider(client, "claude", claudeCwd); + createdAgentIds.push(claudeResult.agentId); + results.push(claudeResult); + console.log(`[PASS] Claude MCP injection verified for agent ${claudeResult.agentId}`); + + if (isProviderAvailable("codex")) { + const codexResult = await verifyInjectedMcpForProvider(client, "codex", codexCwd); + createdAgentIds.push(codexResult.agentId); + results.push(codexResult); + console.log(`[PASS] Codex MCP injection verified for agent ${codexResult.agentId}`); + } else { + console.log("[SKIP] Codex is not available in this environment"); + } + + console.log( + JSON.stringify( + { + ok: true, + results, + }, + null, + 2, + ), + ); + } finally { + for (const agentId of createdAgentIds) { + await client.archiveAgent(agentId).catch(() => undefined); + } + await client.close().catch(() => undefined); + await daemon.close().catch(() => undefined); + await rm(rootCwd, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 001a5bc5b..4006ff478 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -26,17 +26,15 @@ import { z } from "zod"; import { ensureValidJson } from "../json-utils.js"; import type { Logger } from "pino"; -import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js"; -import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; +import type { AgentProvider } from "./agent-sdk-types.js"; +import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; import { AgentPermissionRequestPayloadSchema, AgentPermissionResponseSchema, AgentSnapshotPayloadSchema, - serializeAgentSnapshot, } from "../messages.js"; import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; -import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentStorage } from "./agent-storage.js"; import { appendTimelineItemIfAgentKnown, @@ -48,170 +46,54 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import { expandUserPath } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; +import type { ScheduleService } from "../schedule/service.js"; +import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; +import { + AGENT_PROVIDER_DEFINITIONS, + type ProviderDefinition, +} from "./provider-registry.js"; +import { + AgentModelSchema, + AgentProviderEnum, + AgentStatusEnum, + ProviderSummarySchema, + parseDurationString, + sanitizePermissionRequest, + serializeSnapshotWithMetadata, + startAgentRun, + toScheduleSummary, + waitForAgentWithTimeout, +} from "./mcp-shared.js"; export interface AgentManagementMcpOptions { agentManager: AgentManager; agentStorage: AgentStorage; terminalManager?: TerminalManager | null; + scheduleService?: ScheduleService | null; + providerRegistry?: Record | null; paseoHome?: string; logger: Logger; } -const AgentProviderEnum = z.enum( - AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [ - AgentProvider, - ...AgentProvider[], - ], -); - -const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]); - -// 50 seconds - surface friendly message before SDK tool timeout (~60s) -const AGENT_WAIT_TIMEOUT_MS = 50000; - -async function waitForAgentWithTimeout( - agentManager: AgentManager, - agentId: string, - options?: { - signal?: AbortSignal; - waitForActive?: boolean; - }, -): Promise { - const timeoutController = new AbortController(); - const combinedController = new AbortController(); - - const timeoutId = setTimeout(() => { - timeoutController.abort(new Error("wait timeout")); - }, AGENT_WAIT_TIMEOUT_MS); - - const forwardAbort = (reason: unknown) => { - if (!combinedController.signal.aborted) { - combinedController.abort(reason); - } - }; - - if (options?.signal) { - if (options.signal.aborted) { - forwardAbort(options.signal.reason); - } else { - options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), { - once: true, - }); - } - } - - timeoutController.signal.addEventListener( - "abort", - () => forwardAbort(timeoutController.signal.reason), - { once: true }, - ); - - try { - const result = await agentManager.waitForAgentEvent(agentId, { - signal: combinedController.signal, - waitForActive: options?.waitForActive, - }); - return result; - } catch (error) { - if (error instanceof Error && error.message === "wait timeout") { - const snapshot = agentManager.getAgent(agentId); - const timeline = agentManager.getTimeline(agentId); - const recentActivity = curateAgentActivity(timeline.slice(-5)); - const message = `Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`; - return { - status: snapshot?.lifecycle ?? "idle", - permission: null, - lastMessage: message, - }; - } - throw error; - } finally { - clearTimeout(timeoutId); - } -} - -function startAgentRun( - agentManager: AgentManager, - agentId: string, - prompt: AgentPromptInput, - logger: Logger, - options?: { replaceRunning?: boolean }, -): void { - const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); - const iterator = shouldReplace - ? agentManager.replaceAgentRun(agentId, prompt) - : agentManager.streamAgent(agentId, prompt); - void (async () => { - try { - for await (const _ of iterator) { - // Events are broadcast via AgentManager subscribers. - } - } catch (error) { - logger.error({ err: error, agentId }, "Agent stream failed"); - } - })(); -} - -function sanitizePermissionRequest( - permission: AgentPermissionRequest | null | undefined, -): AgentPermissionRequest | null { - if (!permission) { - return null; - } - const sanitized: AgentPermissionRequest = { ...permission }; - if (sanitized.title === undefined) { - delete sanitized.title; - } - if (sanitized.description === undefined) { - delete sanitized.description; - } - if (sanitized.input === undefined) { - delete sanitized.input; - } - if (sanitized.suggestions === undefined) { - delete sanitized.suggestions; - } - if (sanitized.actions === undefined) { - delete sanitized.actions; - } - if (sanitized.metadata === undefined) { - delete sanitized.metadata; - } - return sanitized; -} - -async function resolveAgentTitle( - agentStorage: AgentStorage, - agentId: string, - logger: Logger, -): Promise { - try { - const record = await agentStorage.get(agentId); - return record?.title ?? null; - } catch (error) { - logger.error({ err: error, agentId }, "Failed to load agent title"); - return null; - } -} - -async function serializeSnapshotWithMetadata( - agentStorage: AgentStorage, - snapshot: ManagedAgent, - logger: Logger, -) { - const title = await resolveAgentTitle(agentStorage, snapshot.id, logger); - return serializeAgentSnapshot(snapshot, { title }); -} - export async function createAgentManagementMcpServer( options: AgentManagementMcpOptions, ): Promise { - const { agentManager, agentStorage, logger } = options; + const { agentManager, agentStorage, scheduleService, providerRegistry, logger } = options; const childLogger = logger.child({ module: "agent", component: "agent-management-mcp", }); const waitTracker = new WaitForAgentTracker(logger); + const resolveNewAgentScheduleTarget = (params?: { + provider?: AgentProvider; + cwd?: string; + }) => ({ + type: "new-agent" as const, + config: { + provider: params?.provider ?? ("claude" as AgentProvider), + cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(), + }, + }); const server = new McpServer({ name: "paseo-agent-management", @@ -231,6 +113,15 @@ export async function createAgentManagementMcpServer( agentType: AgentProviderEnum.optional().describe( "Optional agent implementation to spawn. Defaults to 'claude'.", ), + model: z + .string() + .optional() + .describe("Model to use (e.g. claude-sonnet-4-20250514)"), + thinking: z.string().optional().describe("Thinking option ID"), + labels: z + .record(z.string(), z.string()) + .optional() + .describe("Labels to set on the agent"), initialPrompt: z .string() .optional() @@ -287,6 +178,9 @@ export async function createAgentManagementMcpServer( baseBranch, background = false, title, + model, + thinking, + labels, } = args as { cwd: string; agentType?: AgentProvider; @@ -296,6 +190,9 @@ export async function createAgentManagementMcpServer( baseBranch?: string; background?: boolean; title: string; + model?: string; + thinking?: string; + labels?: Record; }; let resolvedCwd = expandUserPath(cwd); @@ -318,12 +215,18 @@ export async function createAgentManagementMcpServer( const provider: AgentProvider = agentType ?? "claude"; const normalizedTitle = title?.trim() ?? null; - const snapshot = await agentManager.createAgent({ - provider, - cwd: resolvedCwd, - modeId: initialMode, - title: normalizedTitle ?? undefined, - }); + const snapshot = await agentManager.createAgent( + { + provider, + cwd: resolvedCwd, + modeId: initialMode, + title: normalizedTitle ?? undefined, + model, + thinkingOptionId: thinking, + }, + undefined, + labels ? { labels } : undefined, + ); if (worktreeConfig) { void runAsyncWorktreeBootstrap({ @@ -666,6 +569,29 @@ export async function createAgentManagementMcpServer( }, ); + server.registerTool( + "archive_agent", + { + title: "Archive Agent", + description: + "Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.", + inputSchema: { + agentId: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId }) => { + await agentManager.archiveAgent(agentId); + waitTracker.cancel(agentId, "Agent archived"); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + server.registerTool( "kill_agent", { @@ -688,6 +614,281 @@ export async function createAgentManagementMcpServer( }, ); + server.registerTool( + "update_agent", + { + title: "Update Agent", + description: "Update an agent name and/or labels.", + inputSchema: { + agentId: z.string(), + name: z.string().optional(), + labels: z + .record(z.string(), z.string()) + .optional() + .describe("Labels to set on the agent"), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId, name, labels }) => { + const trimmedName = name?.trim(); + if (trimmedName) { + const record = await agentStorage.get(agentId); + if (!record) { + throw new Error(`Agent ${agentId} not found`); + } + await agentStorage.upsert({ + ...record, + title: trimmedName, + updatedAt: new Date().toISOString(), + }); + agentManager.notifyAgentState(agentId); + } + + if (labels) { + await agentManager.setLabels(agentId, labels); + } + + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + server.registerTool( + "create_schedule", + { + title: "Create Schedule", + description: "Create a recurring schedule that runs on an agent or a new agent.", + inputSchema: { + prompt: z.string().trim().min(1, "prompt is required"), + every: z.string().optional(), + cron: z.string().optional(), + name: z.string().optional(), + target: z.enum(["self", "new-agent"]).optional(), + provider: AgentProviderEnum.optional(), + cwd: z.string().optional(), + maxRuns: z.number().int().positive().optional(), + expiresIn: z.string().optional(), + }, + outputSchema: ScheduleSummarySchema.shape, + }, + async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const cadenceCount = Number(every !== undefined) + Number(cron !== undefined); + if (cadenceCount !== 1) { + throw new Error("Specify exactly one of every or cron"); + } + if (target === "self") { + throw new Error("target=self requires a caller agent"); + } + + const schedule = await scheduleService.create({ + prompt: prompt.trim(), + cadence: every + ? { type: "every" as const, everyMs: parseDurationString(every) } + : { type: "cron" as const, expression: cron!.trim() }, + target: resolveNewAgentScheduleTarget({ provider, cwd }), + ...(name?.trim() ? { name: name.trim() } : {}), + ...(maxRuns === undefined ? {} : { maxRuns }), + ...(expiresIn === undefined + ? {} + : { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }), + }); + + return { + content: [], + structuredContent: ensureValidJson(toScheduleSummary(schedule)), + }; + }, + ); + + server.registerTool( + "list_schedules", + { + title: "List Schedules", + description: "List all schedules managed by the daemon.", + inputSchema: {}, + outputSchema: { + schedules: z.array(ScheduleSummarySchema), + }, + }, + async () => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const schedules = (await scheduleService.list()).map((schedule) => toScheduleSummary(schedule)); + return { + content: [], + structuredContent: ensureValidJson({ schedules }), + }; + }, + ); + + server.registerTool( + "inspect_schedule", + { + title: "Inspect Schedule", + description: "Inspect a schedule and its run history.", + inputSchema: { + id: z.string(), + }, + outputSchema: StoredScheduleSchema.shape, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const schedule = await scheduleService.inspect(id); + return { + content: [], + structuredContent: ensureValidJson(schedule), + }; + }, + ); + + server.registerTool( + "pause_schedule", + { + title: "Pause Schedule", + description: "Pause an active schedule.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + await scheduleService.pause(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + server.registerTool( + "resume_schedule", + { + title: "Resume Schedule", + description: "Resume a paused schedule.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + await scheduleService.resume(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + server.registerTool( + "delete_schedule", + { + title: "Delete Schedule", + description: "Delete a schedule permanently.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + await scheduleService.delete(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + server.registerTool( + "list_providers", + { + title: "List Providers", + description: "List available agent providers and their modes.", + inputSchema: {}, + outputSchema: { + providers: z.array(ProviderSummarySchema), + }, + }, + async () => ({ + content: [], + structuredContent: ensureValidJson({ + providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({ + id: provider.id, + label: provider.label, + modes: provider.modes.map((mode) => ({ + id: mode.id, + label: mode.label, + ...(mode.description ? { description: mode.description } : {}), + })), + })), + }), + }), + ); + + server.registerTool( + "list_models", + { + title: "List Models", + description: "List models for an agent provider.", + inputSchema: { + provider: AgentProviderEnum, + }, + outputSchema: { + provider: z.string(), + models: z.array(AgentModelSchema), + }, + }, + async ({ provider }) => { + if (!providerRegistry) { + throw new Error("Provider registry is not configured"); + } + + const definition = providerRegistry[provider]; + if (!definition) { + throw new Error(`Provider ${provider} is not configured`); + } + + const models = await definition.fetchModels(); + return { + content: [], + structuredContent: ensureValidJson({ + provider, + models, + }), + }; + }, + ); + server.registerTool( "get_agent_activity", { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 80161dbb8..68cfa2011 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -300,6 +300,100 @@ describe("AgentManager", () => { }); }); + test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CaptureClient extends TestAgentClient { + lastConfig: AgentSessionConfig | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.lastConfig = config; + return new TestAgentSession(config); + } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + idFactory: () => "00000000-0000-4000-8000-000000000103", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + mcpServers: { + custom: { + type: "stdio", + command: "custom-mcp", + }, + }, + }); + + expect(snapshot.config.mcpServers).toEqual({ + paseo: { + type: "http", + url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`, + }, + custom: { + type: "stdio", + command: "custom-mcp", + }, + }); + expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers); + }); + + test("createAgent preserves a user-provided paseo MCP config", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CaptureClient extends TestAgentClient { + lastConfig: AgentSessionConfig | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.lastConfig = config; + return new TestAgentSession(config); + } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + idFactory: () => "00000000-0000-4000-8000-000000000104", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + mcpServers: { + paseo: { + type: "http", + url: "https://example.com/custom-paseo", + }, + }, + }); + + expect(snapshot.config.mcpServers).toEqual({ + paseo: { + type: "http", + url: "https://example.com/custom-paseo", + }, + }); + expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers); + }); + test("createAgent fails when cwd does not exist", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 53d2fe352..c3cc0836b 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -76,6 +76,7 @@ export type AgentManagerOptions = { idFactory?: () => string; registry?: AgentStorage; onAgentAttention?: AgentAttentionCallback; + mcpBaseUrl?: string; logger: Logger; }; @@ -331,6 +332,7 @@ export class AgentManager { private readonly registry?: AgentStorage; private readonly previousStatuses = new Map(); private readonly backgroundTasks = new Set>(); + private mcpBaseUrl: string | null; private onAgentAttention?: AgentAttentionCallback; private logger: Logger; @@ -345,6 +347,7 @@ export class AgentManager { this.idFactory = options?.idFactory ?? (() => randomUUID()); this.registry = options?.registry; this.onAgentAttention = options?.onAgentAttention; + this.mcpBaseUrl = options?.mcpBaseUrl ?? null; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); if (options?.clients) { for (const [provider, client] of Object.entries(options.clients)) { @@ -363,6 +366,10 @@ export class AgentManager { this.onAgentAttention = callback; } + setMcpBaseUrl(url: string | null): void { + this.mcpBaseUrl = url; + } + public getMetricsSnapshot(): AgentMetricsSnapshot { const byLifecycle: Record = {}; let withActiveForegroundTurn = 0; @@ -731,9 +738,21 @@ export class AgentManager { agentId?: string, options?: { labels?: Record }, ): Promise { - // Generate agent ID early so we can use it in MCP config const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); - const normalizedConfig = await this.normalizeConfig(config); + const injectedConfig = + this.mcpBaseUrl == null + ? config + : { + ...config, + mcpServers: { + paseo: { + type: "http" as const, + url: `${this.mcpBaseUrl}?callerAgentId=${resolvedAgentId}`, + }, + ...(config.mcpServers ?? {}), + }, + }; + const normalizedConfig = await this.normalizeConfig(injectedConfig); const launchContext = this.buildLaunchContext(resolvedAgentId); const client = this.requireClient(normalizedConfig.provider); const available = await client.isAvailable(); diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index 294fbd3f0..82f122752 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -182,6 +182,124 @@ describe("agent MCP end-to-end (offline)", () => { } }, 30_000); + test("create_agent auto-injects paseo MCP by default and can be disabled", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-")); + const port = await getAvailablePort(); + + const daemonConfig: PaseoDaemonConfig = { + listen: `127.0.0.1:${port}`, + paseoHome, + corsAllowedOrigins: [], + allowedHosts: true, + mcpEnabled: true, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + }; + + const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" })); + await daemon.start(); + + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${port}/mcp/agents`), + ); + const client = (await experimental_createMCPClient({ transport })) as McpClient; + + const disabledPaseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-disabled-")); + const disabledStaticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-disabled-")); + const disabledAgentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-disabled-")); + const disabledPort = await getAvailablePort(); + const disabledDaemonConfig: PaseoDaemonConfig = { + listen: `127.0.0.1:${disabledPort}`, + paseoHome: disabledPaseoHome, + corsAllowedOrigins: [], + allowedHosts: true, + mcpEnabled: true, + mcpInjectIntoAgents: false, + staticDir: disabledStaticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(disabledPaseoHome, "agents"), + }; + const disabledDaemon = await createPaseoDaemon( + disabledDaemonConfig, + pino({ level: "silent" }), + ); + await disabledDaemon.start(); + + const disabledTransport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${disabledPort}/mcp/agents`), + ); + const disabledClient = (await experimental_createMCPClient({ + transport: disabledTransport, + })) as McpClient; + + let agentId: string | null = null; + let disabledAgentId: string | null = null; + try { + const result = (await client.callTool({ + name: "create_agent", + args: { + cwd: agentCwd, + title: "Injected MCP", + agentType: "claude", + initialMode: "bypassPermissions", + initialPrompt: "reply with done and stop", + background: true, + }, + })) as McpToolResult; + const payload = getStructuredContent(result); + agentId = (payload?.agentId as string | undefined) ?? null; + expect(agentId).toBeTruthy(); + + const injectedAgent = daemon.agentManager.getAgent(agentId!); + expect(injectedAgent?.config.mcpServers).toMatchObject({ + paseo: { + type: "http", + url: `http://127.0.0.1:${port}/mcp/agents?callerAgentId=${agentId!}`, + }, + }); + + const disabledResult = (await disabledClient.callTool({ + name: "create_agent", + args: { + cwd: disabledAgentCwd, + title: "No injected MCP", + agentType: "claude", + initialMode: "bypassPermissions", + initialPrompt: "reply with done and stop", + background: true, + }, + })) as McpToolResult; + const disabledPayload = getStructuredContent(disabledResult); + disabledAgentId = (disabledPayload?.agentId as string | undefined) ?? null; + expect(disabledAgentId).toBeTruthy(); + + const disabledAgent = disabledDaemon.agentManager.getAgent(disabledAgentId!); + expect(disabledAgent?.config.mcpServers?.paseo).toBeUndefined(); + } finally { + if (agentId) { + await client.callTool({ name: "kill_agent", args: { agentId } }); + } + if (disabledAgentId) { + await disabledClient.callTool({ name: "kill_agent", args: { agentId: disabledAgentId } }); + } + await disabledClient.close(); + await disabledDaemon.stop(); + await rm(disabledPaseoHome, { recursive: true, force: true }); + await rm(disabledStaticDir, { recursive: true, force: true }); + await rm(disabledAgentCwd, { recursive: true, force: true }); + await client.close(); + await daemon.stop(); + await rm(paseoHome, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + await rm(agentCwd, { recursive: true, force: true }); + } + }, 30_000); + test("create_agent with worktree is async and boots terminals only after setup success", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index deebedd7c..5d89a25f6 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -2,6 +2,7 @@ import type { AgentSnapshotPayload } from "../messages.js"; import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js"; import type { AgentCapabilityFlags, + AgentFeature, AgentMetadata, AgentMode, AgentPermissionRequest, @@ -61,7 +62,7 @@ export function toStoredAgentRecord( lastModeId: agent.currentModeId ?? config?.modeId ?? null, config: config ?? null, runtimeInfo, - features: agent.features, + features: normalizeFeatures(agent.features), persistence, requiresAttention: agent.attention.requiresAttention, attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null, @@ -98,7 +99,7 @@ export function toAgentPayload( capabilities: cloneCapabilities(agent.capabilities), currentModeId: agent.currentModeId, availableModes: cloneAvailableModes(agent.availableModes), - features: agent.features, + features: normalizeFeatures(agent.features), pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions), persistence: sanitizePersistenceHandle(agent.persistence), title: options?.title ?? null, @@ -200,6 +201,10 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] { return modes.map((mode) => ({ ...mode })); } +function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] { + return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : []; +} + function sanitizeOptionalJson(value: unknown): JsonValue | undefined { if (value === undefined) { return undefined; diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts new file mode 100644 index 000000000..3d694af01 --- /dev/null +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -0,0 +1,665 @@ +import os from "node:os"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { experimental_createMCPClient } from "ai"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { AGENT_WAIT_TIMEOUT_MS } from "./mcp-shared.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +type StructuredContent = { [key: string]: unknown }; + +type McpToolResult = { + structuredContent?: StructuredContent; + content?: Array<{ structuredContent?: StructuredContent } | StructuredContent>; + isError?: boolean; +}; + +type McpClient = { + callTool: (input: { name: string; args?: StructuredContent }) => Promise; + close: () => Promise; +}; + +function formatHostForHttpUrl(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +function buildExpectedAgentMcpUrl(params: { host: string; port: number; agentId: string }): string { + const baseUrl = new URL("/mcp/agents", `http://${formatHostForHttpUrl(params.host)}:${params.port}`); + baseUrl.searchParams.set("callerAgentId", params.agentId); + return baseUrl.toString(); +} + +function getStructuredContent(result: McpToolResult): StructuredContent | null { + if (result.structuredContent && typeof result.structuredContent === "object") { + return result.structuredContent; + } + const content = result.content?.[0]; + if (content && typeof content === "object" && "structuredContent" in content) { + const structured = (content as { structuredContent?: StructuredContent }).structuredContent; + if (structured) { + return structured; + } + } + if (content && typeof content === "object") { + return content as StructuredContent; + } + return null; +} + +async function createMcpClient(url: string): Promise { + const transport = new StreamableHTTPClientTransport(new URL(url)); + return (await experimental_createMCPClient({ transport })) as McpClient; +} + +async function callToolStructured( + client: McpClient, + name: string, + args?: StructuredContent, +): Promise { + const result = (await client.callTool({ name, args: args ?? {} })) as McpToolResult; + const payload = getStructuredContent(result); + if (!payload) { + throw new Error(`${name} returned no structured payload`); + } + return payload; +} + +async function expectToolError( + client: McpClient, + name: string, + args: StructuredContent, + pattern: RegExp, +): Promise { + const result = (await client.callTool({ name, args })) as McpToolResult; + expect(result.isError).toBe(true); + const content = result.content?.[0] as { text?: string } | undefined; + expect(content?.text ?? "").toMatch(pattern); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor(options: { + timeoutMs: number; + intervalMs?: number; + check: () => Promise | T | null; + label: string; +}): Promise { + const start = Date.now(); + while (Date.now() - start < options.timeoutMs) { + const result = await options.check(); + if (result !== null) { + return result; + } + await sleep(options.intervalMs ?? 50); + } + throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}`); +} + +describe("MCP parity end-to-end", () => { + let tempRoot: string; + let daemonHandle: TestPaseoDaemon; + let topLevelClient: McpClient; + let agentScopedClient: McpClient; + let parentAgentId: string; + let parentAgentCwd: string; + let worktreeRepoCwd: string; + + async function makeCwd(prefix: string): Promise { + return await mkdtemp(path.join(tempRoot, `${prefix}-`)); + } + + async function createTopLevelAgent(args?: Partial): Promise { + const cwd = (args?.cwd as string | undefined) ?? (await makeCwd("agent-cwd")); + const payload = await callToolStructured(topLevelClient, "create_agent", { + cwd, + title: "Parity agent", + agentType: "claude", + initialPrompt: "say done and stop", + initialMode: "bypassPermissions", + background: true, + ...args, + }); + return payload.agentId as string; + } + + async function createChildAgent(args?: Partial): Promise { + const payload = await callToolStructured(agentScopedClient, "create_agent", { + title: "Parity child", + agentType: "claude", + initialPrompt: "say done and stop", + background: true, + ...args, + }); + return payload.agentId as string; + } + + async function archiveAgentIfPresent(agentId: string | null | undefined): Promise { + if (!agentId) { + return; + } + try { + await topLevelClient.callTool({ name: "archive_agent", args: { agentId } }); + } catch { + // ignore cleanup errors + } + } + + async function deleteScheduleIfPresent(id: string | null | undefined): Promise { + if (!id) { + return; + } + try { + await topLevelClient.callTool({ name: "delete_schedule", args: { id } }); + } catch { + // ignore cleanup errors + } + } + + async function killTerminalIfPresent(terminalId: string | null | undefined): Promise { + if (!terminalId) { + return; + } + try { + await agentScopedClient.callTool({ name: "kill_terminal", args: { terminalId } }); + } catch { + // ignore cleanup errors + } + } + + async function archiveWorktreeIfPresent(params: { + cwd: string; + worktreePath?: string | null; + worktreeSlug?: string | null; + }): Promise { + if (!params.worktreePath && !params.worktreeSlug) { + return; + } + try { + await topLevelClient.callTool({ + name: "archive_worktree", + args: { + cwd: params.cwd, + ...(params.worktreePath ? { worktreePath: params.worktreePath } : {}), + ...(params.worktreeSlug ? { worktreeSlug: params.worktreeSlug } : {}), + }, + }); + } catch { + // ignore cleanup errors + } + } + + beforeAll(async () => { + tempRoot = await mkdtemp(path.join(os.tmpdir(), "mcp-parity-e2e-")); + parentAgentCwd = await makeCwd("parent-agent-cwd"); + worktreeRepoCwd = await makeCwd("worktree-repo"); + + daemonHandle = await createTestPaseoDaemon(); + topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`); + + const parentPayload = await callToolStructured(topLevelClient, "create_agent", { + cwd: parentAgentCwd, + title: "MCP parity parent", + agentType: "claude", + initialPrompt: "say done and stop", + initialMode: "bypassPermissions", + background: true, + }); + parentAgentId = parentPayload.agentId as string; + + agentScopedClient = await createMcpClient( + `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${parentAgentId}`, + ); + + execSync("git init -b main", { cwd: worktreeRepoCwd, stdio: "pipe" }); + execSync("git config user.email 'test@example.com'", { cwd: worktreeRepoCwd, stdio: "pipe" }); + execSync("git config user.name 'Test User'", { cwd: worktreeRepoCwd, stdio: "pipe" }); + await writeFile(path.join(worktreeRepoCwd, "README.md"), "# repo\n", "utf8"); + execSync("git add README.md", { cwd: worktreeRepoCwd, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'init'", { + cwd: worktreeRepoCwd, + stdio: "pipe", + }); + }, 30_000); + + afterAll(async () => { + await archiveAgentIfPresent(parentAgentId); + await agentScopedClient?.close(); + await topLevelClient?.close(); + await daemonHandle?.close(); + await rm(tempRoot, { recursive: true, force: true }); + }); + + describe("Suite A: Core Fixes", () => { + test("AGENT_WAIT_TIMEOUT_MS is 30000", () => { + expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000); + }); + + test("create_agent with callerAgentId sets paseo.parent-agent-id label", async () => { + let agentId: string | null = null; + try { + agentId = await createChildAgent(); + const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(snapshot?.labels).toMatchObject({ + "paseo.parent-agent-id": parentAgentId, + }); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + + test("agentManager.createAgent injects paseo MCP using the daemon listen target", async () => { + let agentId: string | null = null; + try { + const listenTarget = daemonHandle.daemon.getListenTarget(); + expect(listenTarget?.type).toBe("tcp"); + + const snapshot = await daemonHandle.daemon.agentManager.createAgent({ + provider: "claude", + cwd: await makeCwd("manager-direct-agent-cwd"), + title: "Manager direct parity agent", + modeId: "bypassPermissions", + }); + agentId = snapshot.id; + + const expectedUrl = buildExpectedAgentMcpUrl({ + host: listenTarget!.host, + port: listenTarget!.port, + agentId, + }); + + expect(snapshot.config.mcpServers).toMatchObject({ + paseo: { + type: "http", + url: expectedUrl, + }, + }); + + const liveAgent = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(liveAgent?.config.mcpServers).toMatchObject({ + paseo: { + type: "http", + url: expectedUrl, + }, + }); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + + test("create_agent accepts model param", async () => { + let agentId: string | null = null; + try { + agentId = await createTopLevelAgent({ model: "claude-test-model" }); + const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(snapshot?.config.model).toBe("claude-test-model"); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + + test("create_agent accepts labels param", async () => { + let agentId: string | null = null; + try { + agentId = await createTopLevelAgent({ labels: { team: "infra" } }); + const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(snapshot?.labels).toMatchObject({ team: "infra" }); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + + test("archive_agent archives an agent", async () => { + let agentId: string | null = null; + try { + agentId = await createTopLevelAgent(); + const archivedAgentId = agentId; + await callToolStructured(topLevelClient, "archive_agent", { agentId }); + agentId = null; + + const agents = daemonHandle.daemon.agentManager.listAgents(); + expect(agents.some((agent) => agent.id === archivedAgentId)).toBe(false); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + + test("update_agent updates name and labels", async () => { + let agentId: string | null = null; + try { + agentId = await createTopLevelAgent(); + await callToolStructured(topLevelClient, "update_agent", { + agentId, + name: "Renamed parity agent", + labels: { team: "infra", surface: "mcp" }, + }); + + const stored = await daemonHandle.daemon.agentStorage.get(agentId); + const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(stored?.title).toBe("Renamed parity agent"); + expect(snapshot?.labels).toMatchObject({ + team: "infra", + surface: "mcp", + }); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + }); + + describe("Suite B: Terminal Tools", () => { + test("create_terminal and list_terminals", async () => { + let terminalId: string | null = null; + try { + const created = await callToolStructured(agentScopedClient, "create_terminal", { + name: "Parity terminal", + }); + terminalId = created.id as string; + + const listed = await callToolStructured(agentScopedClient, "list_terminals"); + const terminals = listed.terminals as Array; + expect(terminals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: terminalId, + name: "Parity terminal", + cwd: parentAgentCwd, + }), + ]), + ); + } finally { + await killTerminalIfPresent(terminalId); + } + }); + + test("send_terminal_keys and capture_terminal", async () => { + let terminalId: string | null = null; + try { + const created = await callToolStructured(agentScopedClient, "create_terminal", { + name: "Parity capture terminal", + }); + terminalId = created.id as string; + + await callToolStructured(agentScopedClient, "send_terminal_keys", { + terminalId, + keys: "echo hello\r", + literal: true, + }); + await sleep(500); + + const captured = await waitFor({ + timeoutMs: 10_000, + intervalMs: 100, + label: "terminal output to contain hello", + check: async () => { + const payload = await callToolStructured(agentScopedClient, "capture_terminal", { + terminalId, + scrollback: true, + }); + const lines = (payload.lines as string[] | undefined) ?? []; + return lines.some((line) => line.includes("hello")) ? payload : null; + }, + }); + + expect(captured.lines).toEqual(expect.arrayContaining([expect.stringContaining("hello")])); + } finally { + await killTerminalIfPresent(terminalId); + } + }); + + test("kill_terminal removes terminal", async () => { + let terminalId: string | null = null; + try { + const created = await callToolStructured(agentScopedClient, "create_terminal", { + name: "Parity kill terminal", + }); + terminalId = created.id as string; + + await callToolStructured(agentScopedClient, "kill_terminal", { terminalId }); + terminalId = null; + + const listed = await waitFor({ + timeoutMs: 5_000, + intervalMs: 100, + label: "terminal removal", + check: async () => { + const payload = await callToolStructured(agentScopedClient, "list_terminals"); + const terminals = payload.terminals as Array; + return terminals.some((terminal) => terminal.id === created.id) ? null : payload; + }, + }); + const terminals = listed.terminals as Array; + expect(terminals.some((terminal) => terminal.id === created.id)).toBe(false); + } finally { + await killTerminalIfPresent(terminalId); + } + }); + + test("kill_terminal with invalid id throws", async () => { + await expectToolError( + agentScopedClient, + "kill_terminal", + { terminalId: "missing-terminal-id" }, + /not found/i, + ); + }); + }); + + describe("Suite C: Schedule Tools", () => { + test("create_schedule and list_schedules", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(topLevelClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity schedule list", + }); + scheduleId = created.id as string; + + const listed = await callToolStructured(topLevelClient, "list_schedules"); + const schedules = listed.schedules as Array; + expect(schedules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: scheduleId, + name: "Parity schedule list", + }), + ]), + ); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("inspect_schedule returns details", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(topLevelClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity inspect schedule", + }); + scheduleId = created.id as string; + + const inspected = await callToolStructured(topLevelClient, "inspect_schedule", { + id: scheduleId, + }); + expect(inspected).toMatchObject({ + id: scheduleId, + name: "Parity inspect schedule", + prompt: "say hello", + status: "active", + }); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("pause and resume schedule", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(topLevelClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity pause schedule", + }); + scheduleId = created.id as string; + + await callToolStructured(topLevelClient, "pause_schedule", { id: scheduleId }); + const paused = await callToolStructured(topLevelClient, "inspect_schedule", { id: scheduleId }); + expect(paused.status).toBe("paused"); + + await callToolStructured(topLevelClient, "resume_schedule", { id: scheduleId }); + const resumed = await callToolStructured(topLevelClient, "inspect_schedule", { id: scheduleId }); + expect(resumed.status).toBe("active"); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("delete_schedule removes schedule", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(topLevelClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity delete schedule", + }); + scheduleId = created.id as string; + + await callToolStructured(topLevelClient, "delete_schedule", { id: scheduleId }); + scheduleId = null; + + const listed = await callToolStructured(topLevelClient, "list_schedules"); + const schedules = listed.schedules as Array; + expect(schedules.some((schedule) => schedule.id === created.id)).toBe(false); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("create_schedule target self with callerAgentId", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(agentScopedClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity self schedule", + target: "self", + }); + scheduleId = created.id as string; + expect(created.target).toMatchObject({ + type: "agent", + agentId: parentAgentId, + }); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("create_schedule target self without callerAgentId throws", async () => { + await expectToolError( + topLevelClient, + "create_schedule", + { + prompt: "say hello", + every: "5m", + target: "self", + }, + /requires a caller agent/i, + ); + }); + }); + + describe("Suite D: Provider Tools", () => { + test("list_providers returns providers", async () => { + const payload = await callToolStructured(topLevelClient, "list_providers"); + const providers = payload.providers as Array; + expect(Array.isArray(providers)).toBe(true); + expect(providers.length).toBeGreaterThan(0); + expect(providers[0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + label: expect.any(String), + modes: expect.any(Array), + }), + ); + }); + + test("list_models returns models for provider", async () => { + const payload = await callToolStructured(topLevelClient, "list_models", { + provider: "claude", + }); + expect(payload.provider).toBe("claude"); + expect(Array.isArray(payload.models)).toBe(true); + }); + }); + + describe("Suite E: Worktree Tools", () => { + test("list_worktrees on empty repo", async () => { + const payload = await callToolStructured(topLevelClient, "list_worktrees", { + cwd: worktreeRepoCwd, + }); + expect(payload.worktrees).toEqual([]); + }); + + test("create_worktree and list_worktrees", async () => { + let worktreePath: string | null = null; + const branchName = `parity-create-${Date.now()}`; + try { + const created = await callToolStructured(topLevelClient, "create_worktree", { + cwd: worktreeRepoCwd, + branchName, + baseBranch: "main", + }); + worktreePath = created.worktreePath as string; + + const listed = await callToolStructured(topLevelClient, "list_worktrees", { + cwd: worktreeRepoCwd, + }); + const worktrees = listed.worktrees as Array; + expect(worktrees).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: worktreePath, + branchName, + }), + ]), + ); + } finally { + await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); + } + }); + + test("archive_worktree removes worktree", async () => { + let worktreePath: string | null = null; + const branchName = `parity-archive-${Date.now()}`; + try { + const created = await callToolStructured(topLevelClient, "create_worktree", { + cwd: worktreeRepoCwd, + branchName, + baseBranch: "main", + }); + worktreePath = created.worktreePath as string; + + await callToolStructured(topLevelClient, "archive_worktree", { + cwd: worktreeRepoCwd, + worktreePath, + }); + worktreePath = null; + + const listed = await callToolStructured(topLevelClient, "list_worktrees", { + cwd: worktreeRepoCwd, + }); + const worktrees = listed.worktrees as Array; + expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false); + } finally { + await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); + } + }); + }); +}); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 8c8a0b117..e4c471ebf 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -23,7 +23,10 @@ function createTestDeps(): TestDeps { waitForAgentEvent: vi.fn(), recordUserMessage: vi.fn(), setAgentMode: vi.fn(), + setLabels: vi.fn().mockResolvedValue(undefined), setTitle: vi.fn().mockResolvedValue(undefined), + archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }), + notifyAgentState: vi.fn(), getAgent: vi.fn(), streamAgent: vi.fn(() => (async function* noop() {})()), respondToPermission: vi.fn(), @@ -34,6 +37,7 @@ function createTestDeps(): TestDeps { const agentStorageSpies = { get: vi.fn().mockResolvedValue(null), setTitle: vi.fn().mockResolvedValue(undefined), + upsert: vi.fn().mockResolvedValue(undefined), applySnapshot: vi.fn(), list: vi.fn(), remove: vi.fn(), @@ -174,6 +178,41 @@ describe("create_agent MCP tool", () => { ); }); + it("passes optional model, thinking, and labels through createAgent", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.createAgent.mockResolvedValue({ + id: "agent-789", + cwd: "/tmp/repo", + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Config test", model: "claude-sonnet-4-20250514" }, + } as ManagedAgent); + + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = (server as any)._registeredTools["create_agent"]; + await tool.callback({ + cwd: existingCwd, + title: "Config test", + initialMode: "default", + initialPrompt: "Do work", + model: "claude-sonnet-4-20250514", + thinking: "think-hard", + labels: { source: "mcp" }, + }); + + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: existingCwd, + title: "Config test", + model: "claude-sonnet-4-20250514", + thinkingOptionId: "think-hard", + }), + undefined, + { labels: { source: "mcp" } }, + ); + }); + it("allows caller agents to override cwd and applies caller context labels", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const baseDir = await mkdtemp(join(tmpdir(), "paseo-mcp-test-")); @@ -218,10 +257,49 @@ describe("create_agent MCP tool", () => { cwd: subdir, }), undefined, - { labels: { source: "voice" } }, + { + labels: { + "paseo.parent-agent-id": "voice-agent", + source: "voice", + }, + }, ); await rm(baseDir, { recursive: true, force: true }); }); + + it("delegates MCP injection to AgentManager and passes through an undefined agent ID", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.createAgent.mockResolvedValue({ + id: "agent-injected-123", + cwd: "/tmp/repo", + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Injected config test" }, + } as ManagedAgent); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + }); + const tool = (server as any)._registeredTools["create_agent"]; + await tool.callback({ + cwd: existingCwd, + title: "Injected config test", + initialMode: "default", + initialPrompt: "Do work", + }); + + const [configArg, agentIdArg, optionsArg] = spies.agentManager.createAgent.mock.calls[0]; + expect(configArg).toMatchObject({ + cwd: existingCwd, + title: "Injected config test", + }); + expect(configArg.mcpServers).toBeUndefined(); + expect(agentIdArg).toBeUndefined(); + expect(optionsArg).toBeUndefined(); + }); }); describe("speak MCP tool", () => { @@ -278,3 +356,54 @@ describe("speak MCP tool", () => { expect(tool).toBeUndefined(); }); }); + +describe("agent snapshot MCP serialization", () => { + const logger = createTestLogger(); + + it("normalizes null features to an empty array for list_agents", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.listAgents = vi.fn().mockReturnValue([ + { + id: "agent-null-features", + provider: "claude", + cwd: "/tmp/repo", + config: {}, + runtimeInfo: undefined, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + lastUserMessageAt: null, + lifecycle: "idle", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: false, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + features: null, + pendingPermissions: new Map(), + persistence: null, + labels: {}, + attention: { requiresAttention: false }, + } as unknown as ManagedAgent, + ]); + + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = (server as any)._registeredTools["list_agents"]; + const response = await tool.callback({}); + const structured = response.structuredContent; + + expect(structured).toEqual({ + agents: [ + expect.objectContaining({ + id: "agent-null-features", + features: [], + }), + ], + }); + expect(Array.isArray(structured.agents[0].features)).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index a982bc483..f62409e31 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -5,17 +5,15 @@ import type { Logger } from "pino"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js"; -import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js"; -import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; +import type { AgentProvider } from "./agent-sdk-types.js"; +import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; import { AgentPermissionRequestPayloadSchema, AgentPermissionResponseSchema, AgentSnapshotPayloadSchema, - serializeAgentSnapshot, } from "../messages.js"; import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; -import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentStorage } from "./agent-storage.js"; import { appendTimelineItemIfAgentKnown, @@ -27,12 +25,34 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; +import { captureTerminalLines } from "../../terminal/terminal.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; +import type { ScheduleService } from "../schedule/service.js"; +import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; +import { + AGENT_PROVIDER_DEFINITIONS, + type ProviderDefinition, +} from "./provider-registry.js"; +import { deletePaseoWorktree, listPaseoWorktrees } from "../../utils/worktree.js"; +import { + AgentModelSchema, + AgentProviderEnum, + AgentStatusEnum, + ProviderSummarySchema, + parseDurationString, + sanitizePermissionRequest, + serializeSnapshotWithMetadata, + startAgentRun, + toScheduleSummary, + waitForAgentWithTimeout, +} from "./mcp-shared.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; agentStorage: AgentStorage; terminalManager?: TerminalManager | null; + scheduleService?: ScheduleService | null; + providerRegistry?: Record | null; paseoHome?: string; /** * ID of the agent that is connecting to this MCP server. @@ -91,18 +111,6 @@ function mapModeAcrossProviders( return sourceMode; } -const AgentProviderEnum = z.enum( - AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [ - AgentProvider, - ...AgentProvider[], - ], -); - -const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]); - -// 50 seconds - surface friendly message before SDK tool timeout (~60s) -const AGENT_WAIT_TIMEOUT_MS = 50000; - type McpToolContext = RequestHandlerExtra; function resolveChildAgentCwd(params: { @@ -124,152 +132,59 @@ function resolveChildAgentCwd(params: { return resolvePathFromBase(params.parentCwd, requestedCwd); } -/** - * Wraps agentManager.waitForAgentEvent with a self-imposed timeout. - * Returns a friendly message when timeout occurs, rather than letting - * the SDK tool timeout trigger a generic "tool failed" error. - */ -async function waitForAgentWithTimeout( - agentManager: AgentManager, - agentId: string, - options?: { - signal?: AbortSignal; - waitForActive?: boolean; - }, -): Promise { - const timeoutController = new AbortController(); - const combinedController = new AbortController(); +const TerminalSummarySchema = z.object({ + id: z.string(), + name: z.string(), + cwd: z.string(), +}); - const timeoutId = setTimeout(() => { - timeoutController.abort(new Error("wait timeout")); - }, AGENT_WAIT_TIMEOUT_MS); +const WorktreeSummarySchema = z.object({ + path: z.string(), + createdAt: z.string(), + branchName: z.string().optional(), + head: z.string().optional(), +}); - const forwardAbort = (reason: unknown) => { - if (!combinedController.signal.aborted) { - combinedController.abort(reason); - } - }; - - // Forward external signal abort - if (options?.signal) { - if (options.signal.aborted) { - forwardAbort(options.signal.reason); - } else { - options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), { - once: true, - }); - } +function resolveTerminalKeyToken(key: string, literal: boolean): string { + if (literal) { + return key; } - // Forward timeout abort - timeoutController.signal.addEventListener( - "abort", - () => forwardAbort(timeoutController.signal.reason), - { once: true }, - ); - - try { - const result = await agentManager.waitForAgentEvent(agentId, { - signal: combinedController.signal, - waitForActive: options?.waitForActive, - }); - return result; - } catch (error) { - if (error instanceof Error && error.message === "wait timeout") { - const snapshot = agentManager.getAgent(agentId); - const timeline = agentManager.getTimeline(agentId); - const recentActivity = curateAgentActivity(timeline.slice(-5)); - const message = `Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`; - return { - status: snapshot?.lifecycle ?? "idle", - permission: null, - lastMessage: message, - }; - } - throw error; - } finally { - clearTimeout(timeoutId); + switch (key) { + case "Enter": + return "\r"; + case "Tab": + return "\t"; + case "Escape": + return "\u001b"; + case "Space": + return " "; + case "BSpace": + return "\u007f"; + case "C-c": + return "\u0003"; + case "C-d": + return "\u0004"; + case "C-z": + return "\u001a"; + case "C-l": + return "\u000c"; + case "C-a": + return "\u0001"; + case "C-e": + return "\u0005"; + default: + return key; } } -function startAgentRun( - agentManager: AgentManager, - agentId: string, - prompt: AgentPromptInput, - logger: Logger, - options?: { replaceRunning?: boolean }, -): void { - const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); - const iterator = shouldReplace - ? agentManager.replaceAgentRun(agentId, prompt) - : agentManager.streamAgent(agentId, prompt); - void (async () => { - try { - for await (const _ of iterator) { - // Events are broadcast via AgentManager subscribers. - } - } catch (error) { - logger.error({ err: error, agentId }, "Agent stream failed"); - } - })(); -} - -function sanitizePermissionRequest( - permission: AgentPermissionRequest | null | undefined, -): AgentPermissionRequest | null { - if (!permission) { - return null; - } - const sanitized: AgentPermissionRequest = { ...permission }; - if (sanitized.title === undefined) { - delete sanitized.title; - } - if (sanitized.description === undefined) { - delete sanitized.description; - } - if (sanitized.input === undefined) { - delete sanitized.input; - } - if (sanitized.suggestions === undefined) { - delete sanitized.suggestions; - } - if (sanitized.actions === undefined) { - delete sanitized.actions; - } - if (sanitized.metadata === undefined) { - delete sanitized.metadata; - } - return sanitized; -} - -async function resolveAgentTitle( - agentStorage: AgentStorage, - agentId: string, - logger: Logger, -): Promise { - try { - const record = await agentStorage.get(agentId); - return record?.title ?? null; - } catch (error) { - logger.error({ err: error, agentId }, "Failed to load agent title"); - return null; - } -} - -async function serializeSnapshotWithMetadata( - agentStorage: AgentStorage, - snapshot: ManagedAgent, - logger: Logger, -) { - const title = await resolveAgentTitle(agentStorage, snapshot.id, logger); - return serializeAgentSnapshot(snapshot, { title }); -} - export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise { const { agentManager, agentStorage, terminalManager, + scheduleService, + providerRegistry, callerAgentId, resolveSpeakHandler, resolveCallerContext, @@ -284,6 +199,82 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom version: "2.0.0", }); + const resolveCallerAgent = () => { + if (!callerAgentId) { + return null; + } + const parentAgent = agentManager.getAgent(callerAgentId); + if (!parentAgent) { + throw new Error(`Parent agent ${callerAgentId} not found`); + } + return parentAgent; + }; + + const resolveScopedCwd = (requestedCwd?: string, options?: { required?: boolean }): string => { + const callerAgent = resolveCallerAgent(); + if (callerAgent) { + return resolveChildAgentCwd({ + parentCwd: callerAgent.cwd, + requestedCwd, + lockedCwd: callerContext?.lockedCwd, + allowCustomCwd: callerContext?.allowCustomCwd ?? true, + }); + } + + const trimmedCwd = requestedCwd?.trim(); + if (!trimmedCwd) { + if (options?.required) { + throw new Error("cwd is required"); + } + throw new Error("cwd is required when no caller agent is available"); + } + + return expandUserPath(trimmedCwd); + }; + + const resolveNewAgentScheduleTarget = () => { + const callerAgent = resolveCallerAgent(); + if (callerAgent) { + return { + type: "new-agent" as const, + config: { + provider: callerAgent.provider, + cwd: callerAgent.cwd, + ...(callerAgent.currentModeId ? { modeId: callerAgent.currentModeId } : {}), + ...(callerAgent.config.model ? { model: callerAgent.config.model } : {}), + ...(callerAgent.config.thinkingOptionId + ? { thinkingOptionId: callerAgent.config.thinkingOptionId } + : {}), + ...(callerAgent.config.approvalPolicy + ? { approvalPolicy: callerAgent.config.approvalPolicy } + : {}), + ...(callerAgent.config.sandboxMode + ? { sandboxMode: callerAgent.config.sandboxMode } + : {}), + ...(typeof callerAgent.config.networkAccess === "boolean" + ? { networkAccess: callerAgent.config.networkAccess } + : {}), + ...(typeof callerAgent.config.webSearch === "boolean" + ? { webSearch: callerAgent.config.webSearch } + : {}), + ...(callerAgent.config.title ? { title: callerAgent.config.title } : {}), + ...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}), + ...(callerAgent.config.systemPrompt + ? { systemPrompt: callerAgent.config.systemPrompt } + : {}), + ...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}), + }, + }; + } + + return { + type: "new-agent" as const, + config: { + provider: "claude" as AgentProvider, + cwd: process.cwd(), + }, + }; + }; const agentToAgentInputSchema = { cwd: z .string() @@ -298,6 +289,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom agentType: AgentProviderEnum.optional().describe( "Optional agent implementation to spawn. Defaults to 'claude'.", ), + model: z + .string() + .optional() + .describe("Model to use (e.g. claude-sonnet-4-20250514)"), + thinking: z.string().optional().describe("Thinking option ID"), + labels: z + .record(z.string(), z.string()) + .optional() + .describe("Labels to set on the agent"), initialPrompt: z .string() .trim() @@ -325,6 +325,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom agentType: AgentProviderEnum.optional().describe( "Optional agent implementation to spawn. Defaults to 'claude'.", ), + model: z + .string() + .optional() + .describe("Model to use (e.g. claude-sonnet-4-20250514)"), + thinking: z.string().optional().describe("Thinking option ID"), + labels: z + .record(z.string(), z.string()) + .optional() + .describe("Labels to set on the agent"), initialPrompt: z .string() .trim() @@ -427,6 +436,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom let initialPrompt: string; let background = false; let normalizedTitle: string | null; + let model: string | undefined; + let thinking: string | undefined; + let labels: Record | undefined; let resolvedCwd: string; let resolvedMode: string | undefined; @@ -438,6 +450,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom initialPrompt = callerArgs.initialPrompt; background = callerArgs.background ?? false; normalizedTitle = callerArgs.title.trim(); + model = callerArgs.model; + thinking = callerArgs.thinking; + labels = callerArgs.labels; const parentAgent = agentManager.getAgent(callerAgentId); if (!parentAgent) { @@ -459,6 +474,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom initialPrompt = topLevelArgs.initialPrompt; background = topLevelArgs.background ?? false; normalizedTitle = topLevelArgs.title.trim(); + model = topLevelArgs.model; + thinking = topLevelArgs.thinking; + labels = topLevelArgs.labels; const { cwd, initialMode, worktreeName, baseBranch } = topLevelArgs; resolvedCwd = expandUserPath(cwd); @@ -481,19 +499,23 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom resolvedMode = initialMode; } - const childAgentDefaultLabels = - callerAgentId && callerContext?.childAgentDefaultLabels - ? callerContext.childAgentDefaultLabels - : undefined; + const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels; + const mergedLabels = { + ...(callerAgentId ? { "paseo.parent-agent-id": callerAgentId } : {}), + ...(childAgentDefaultLabels ?? {}), + ...(labels ?? {}), + }; const snapshot = await agentManager.createAgent( { provider, cwd: resolvedCwd, modeId: resolvedMode, title: normalizedTitle ?? undefined, + model, + thinkingOptionId: thinking, }, undefined, - childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined, + Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined, ); if (worktreeConfig) { @@ -840,6 +862,29 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); + server.registerTool( + "archive_agent", + { + title: "Archive Agent", + description: + "Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.", + inputSchema: { + agentId: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId }) => { + await agentManager.archiveAgent(agentId); + waitTracker.cancel(agentId, "Agent archived"); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + server.registerTool( "kill_agent", { @@ -862,6 +907,582 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); + server.registerTool( + "update_agent", + { + title: "Update Agent", + description: "Update an agent name and/or labels.", + inputSchema: { + agentId: z.string(), + name: z.string().optional(), + labels: z + .record(z.string(), z.string()) + .optional() + .describe("Labels to set on the agent"), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId, name, labels }) => { + const trimmedName = name?.trim(); + if (trimmedName) { + const record = await agentStorage.get(agentId); + if (!record) { + throw new Error(`Agent ${agentId} not found`); + } + await agentStorage.upsert({ + ...record, + title: trimmedName, + updatedAt: new Date().toISOString(), + }); + agentManager.notifyAgentState(agentId); + } + + if (labels) { + await agentManager.setLabels(agentId, labels); + } + + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + server.registerTool( + "list_terminals", + { + title: "List Terminals", + description: "List terminals for a working directory or across all working directories.", + inputSchema: { + cwd: z + .string() + .optional() + .describe("Optional working directory. Defaults to the caller agent cwd."), + all: z + .boolean() + .optional() + .describe("List terminals across all working directories."), + }, + outputSchema: { + terminals: z.array(TerminalSummarySchema), + }, + }, + async ({ cwd, all }) => { + if (!terminalManager) { + throw new Error("Terminal manager is not configured"); + } + + const terminals = all + ? ( + await Promise.all( + terminalManager.listDirectories().map(async (directory) => + (await terminalManager.getTerminals(directory)).map((terminal) => ({ + id: terminal.id, + name: terminal.name, + cwd: terminal.cwd, + })), + ), + ) + ).flat() + : (await terminalManager.getTerminals(resolveScopedCwd(cwd, { required: true }))).map( + (terminal) => ({ + id: terminal.id, + name: terminal.name, + cwd: terminal.cwd, + }), + ); + + return { + content: [], + structuredContent: ensureValidJson({ terminals }), + }; + }, + ); + + server.registerTool( + "create_terminal", + { + title: "Create Terminal", + description: "Create a terminal session for a working directory.", + inputSchema: { + cwd: z + .string() + .optional() + .describe("Optional working directory. Defaults to the caller agent cwd."), + name: z.string().optional().describe("Optional terminal name."), + }, + outputSchema: TerminalSummarySchema.shape, + }, + async ({ cwd, name }) => { + if (!terminalManager) { + throw new Error("Terminal manager is not configured"); + } + + const terminal = await terminalManager.createTerminal({ + cwd: resolveScopedCwd(cwd, { required: true }), + ...(name?.trim() ? { name: name.trim() } : {}), + }); + + return { + content: [], + structuredContent: ensureValidJson({ + id: terminal.id, + name: terminal.name, + cwd: terminal.cwd, + }), + }; + }, + ); + + server.registerTool( + "kill_terminal", + { + title: "Kill Terminal", + description: "Kill an existing terminal session.", + inputSchema: { + terminalId: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ terminalId }) => { + if (!terminalManager) { + throw new Error("Terminal manager is not configured"); + } + + const terminal = terminalManager.getTerminal(terminalId); + if (!terminal) { + throw new Error(`Terminal ${terminalId} not found`); + } + + terminal.kill(); + + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + server.registerTool( + "capture_terminal", + { + title: "Capture Terminal", + description: "Capture plain-text terminal output lines from a terminal session.", + inputSchema: { + terminalId: z.string(), + start: z.number().optional(), + end: z.number().optional(), + scrollback: z.boolean().optional(), + stripAnsi: z.boolean().optional().default(true), + }, + outputSchema: { + terminalId: z.string(), + lines: z.array(z.string()), + totalLines: z.number().int().nonnegative(), + }, + }, + async ({ terminalId, start, end, scrollback, stripAnsi = true }) => { + if (!terminalManager) { + throw new Error("Terminal manager is not configured"); + } + + const terminal = terminalManager.getTerminal(terminalId); + if (!terminal) { + throw new Error(`Terminal ${terminalId} not found`); + } + + const capture = captureTerminalLines(terminal, { + start: scrollback ? 0 : start, + end, + stripAnsi, + }); + + return { + content: [], + structuredContent: ensureValidJson({ + terminalId, + lines: capture.lines, + totalLines: capture.totalLines, + }), + }; + }, + ); + + server.registerTool( + "send_terminal_keys", + { + title: "Send Terminal Keys", + description: "Send literal text or special key tokens to a terminal session.", + inputSchema: { + terminalId: z.string(), + keys: z.string(), + literal: z.boolean().optional(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ terminalId, keys, literal = false }) => { + if (!terminalManager) { + throw new Error("Terminal manager is not configured"); + } + + const terminal = terminalManager.getTerminal(terminalId); + if (!terminal) { + throw new Error(`Terminal ${terminalId} not found`); + } + + terminal.send({ + type: "input", + data: resolveTerminalKeyToken(keys, literal), + }); + + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + server.registerTool( + "create_schedule", + { + title: "Create Schedule", + description: "Create a recurring schedule that runs on an agent or a new agent.", + inputSchema: { + prompt: z.string().trim().min(1, "prompt is required"), + every: z.string().optional(), + cron: z.string().optional(), + name: z.string().optional(), + target: z.enum(["self", "new-agent"]).optional(), + maxRuns: z.number().int().positive().optional(), + expiresIn: z.string().optional(), + }, + outputSchema: ScheduleSummarySchema.shape, + }, + async ({ prompt, every, cron, name, target, maxRuns, expiresIn }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const cadenceCount = Number(every !== undefined) + Number(cron !== undefined); + if (cadenceCount !== 1) { + throw new Error("Specify exactly one of every or cron"); + } + + const scheduleTarget = + target === "self" + ? (() => { + if (!callerAgentId) { + throw new Error("target=self requires a caller agent"); + } + return { type: "agent" as const, agentId: callerAgentId }; + })() + : resolveNewAgentScheduleTarget(); + + const schedule = await scheduleService.create({ + prompt: prompt.trim(), + cadence: every + ? { type: "every" as const, everyMs: parseDurationString(every) } + : { type: "cron" as const, expression: cron!.trim() }, + target: scheduleTarget, + ...(name?.trim() ? { name: name.trim() } : {}), + ...(maxRuns === undefined ? {} : { maxRuns }), + ...(expiresIn === undefined + ? {} + : { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }), + }); + + return { + content: [], + structuredContent: ensureValidJson(toScheduleSummary(schedule)), + }; + }, + ); + + server.registerTool( + "list_schedules", + { + title: "List Schedules", + description: "List all schedules managed by the daemon.", + inputSchema: {}, + outputSchema: { + schedules: z.array(ScheduleSummarySchema), + }, + }, + async () => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const schedules = (await scheduleService.list()).map((schedule) => toScheduleSummary(schedule)); + return { + content: [], + structuredContent: ensureValidJson({ schedules }), + }; + }, + ); + + server.registerTool( + "inspect_schedule", + { + title: "Inspect Schedule", + description: "Inspect a schedule and its run history.", + inputSchema: { + id: z.string(), + }, + outputSchema: StoredScheduleSchema.shape, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const schedule = await scheduleService.inspect(id); + return { + content: [], + structuredContent: ensureValidJson(schedule), + }; + }, + ); + + server.registerTool( + "pause_schedule", + { + title: "Pause Schedule", + description: "Pause an active schedule.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + await scheduleService.pause(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + server.registerTool( + "resume_schedule", + { + title: "Resume Schedule", + description: "Resume a paused schedule.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + await scheduleService.resume(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + server.registerTool( + "delete_schedule", + { + title: "Delete Schedule", + description: "Delete a schedule permanently.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + await scheduleService.delete(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + server.registerTool( + "list_providers", + { + title: "List Providers", + description: "List available agent providers and their modes.", + inputSchema: {}, + outputSchema: { + providers: z.array(ProviderSummarySchema), + }, + }, + async () => ({ + content: [], + structuredContent: ensureValidJson({ + providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({ + id: provider.id, + label: provider.label, + modes: provider.modes.map((mode) => ({ + id: mode.id, + label: mode.label, + ...(mode.description ? { description: mode.description } : {}), + })), + })), + }), + }), + ); + + server.registerTool( + "list_models", + { + title: "List Models", + description: "List models for an agent provider.", + inputSchema: { + provider: AgentProviderEnum, + }, + outputSchema: { + provider: z.string(), + models: z.array(AgentModelSchema), + }, + }, + async ({ provider }) => { + if (!providerRegistry) { + throw new Error("Provider registry is not configured"); + } + + const definition = providerRegistry[provider]; + if (!definition) { + throw new Error(`Provider ${provider} is not configured`); + } + + const models = await definition.fetchModels(); + return { + content: [], + structuredContent: ensureValidJson({ + provider, + models, + }), + }; + }, + ); + + server.registerTool( + "list_worktrees", + { + title: "List Worktrees", + description: "List Paseo-managed git worktrees for a repository.", + inputSchema: { + cwd: z + .string() + .optional() + .describe("Optional repository cwd. Defaults to the caller agent cwd."), + }, + outputSchema: { + worktrees: z.array(WorktreeSummarySchema), + }, + }, + async ({ cwd }) => { + const resolvedCwd = resolveScopedCwd(cwd, { required: true }); + const worktrees = await listPaseoWorktrees({ + cwd: resolvedCwd, + paseoHome: options.paseoHome, + }); + + return { + content: [], + structuredContent: ensureValidJson({ worktrees }), + }; + }, + ); + + server.registerTool( + "create_worktree", + { + title: "Create Worktree", + description: "Create a Paseo-managed git worktree.", + inputSchema: { + cwd: z + .string() + .optional() + .describe("Optional repository cwd. Defaults to the caller agent cwd."), + branchName: z.string(), + baseBranch: z.string(), + }, + outputSchema: { + branchName: z.string(), + worktreePath: z.string(), + }, + }, + async ({ cwd, branchName, baseBranch }) => { + const worktree = await createAgentWorktree({ + branchName, + cwd: resolveScopedCwd(cwd, { required: true }), + baseBranch, + worktreeSlug: branchName, + paseoHome: options.paseoHome, + }); + + return { + content: [], + structuredContent: ensureValidJson({ + branchName, + worktreePath: worktree.worktreePath, + }), + }; + }, + ); + + server.registerTool( + "archive_worktree", + { + title: "Archive Worktree", + description: "Delete a Paseo-managed git worktree.", + inputSchema: { + cwd: z + .string() + .optional() + .describe("Optional repository cwd. Defaults to the caller agent cwd."), + worktreePath: z.string().optional(), + worktreeSlug: z.string().optional(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ cwd, worktreePath, worktreeSlug }) => { + await deletePaseoWorktree({ + cwd: resolveScopedCwd(cwd, { required: true }), + worktreePath, + worktreeSlug, + paseoHome: options.paseoHome, + }); + + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + server.registerTool( "get_agent_activity", { diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts new file mode 100644 index 000000000..460ec2d88 --- /dev/null +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -0,0 +1,238 @@ +import { z } from "zod"; +import type { Logger } from "pino"; + +import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js"; +import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; +import { curateAgentActivity } from "./activity-curator.js"; +import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; +import type { AgentStorage } from "./agent-storage.js"; +import { serializeAgentSnapshot } from "../messages.js"; +import { StoredScheduleSchema } from "../schedule/types.js"; + +export const AgentProviderEnum = z.enum( + AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [ + AgentProvider, + ...AgentProvider[], + ], +); + +export const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]); + +export const ProviderModeSchema = z.object({ + id: z.string(), + label: z.string(), + description: z.string().optional(), +}); + +export const ProviderSummarySchema = z.object({ + id: z.string(), + label: z.string(), + modes: z.array(ProviderModeSchema), +}); + +export const AgentSelectOptionSchema = z.object({ + id: z.string(), + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const AgentModelSchema = z.object({ + provider: z.string(), + id: z.string(), + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + metadata: z.record(z.unknown()).optional(), + thinkingOptions: z.array(AgentSelectOptionSchema).optional(), + defaultThinkingOptionId: z.string().optional(), +}); + +// 30 seconds - surface friendly message before SDK tool timeout (~60s) +export const AGENT_WAIT_TIMEOUT_MS = 30000; + +export type StartAgentRunOptions = { + replaceRunning?: boolean; +}; + +/** + * Wraps agentManager.waitForAgentEvent with a self-imposed timeout. + * Returns a friendly message when timeout occurs, rather than letting + * the SDK tool timeout trigger a generic "tool failed" error. + */ +export async function waitForAgentWithTimeout( + agentManager: AgentManager, + agentId: string, + options?: { + signal?: AbortSignal; + waitForActive?: boolean; + }, +): Promise { + const timeoutController = new AbortController(); + const combinedController = new AbortController(); + + const timeoutId = setTimeout(() => { + timeoutController.abort(new Error("wait timeout")); + }, AGENT_WAIT_TIMEOUT_MS); + + const forwardAbort = (reason: unknown) => { + if (!combinedController.signal.aborted) { + combinedController.abort(reason); + } + }; + + if (options?.signal) { + if (options.signal.aborted) { + forwardAbort(options.signal.reason); + } else { + options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), { + once: true, + }); + } + } + + timeoutController.signal.addEventListener( + "abort", + () => forwardAbort(timeoutController.signal.reason), + { once: true }, + ); + + try { + const result = await agentManager.waitForAgentEvent(agentId, { + signal: combinedController.signal, + waitForActive: options?.waitForActive, + }); + return result; + } catch (error) { + if (error instanceof Error && error.message === "wait timeout") { + const snapshot = agentManager.getAgent(agentId); + const timeline = agentManager.getTimeline(agentId); + const recentActivity = curateAgentActivity(timeline.slice(-5)); + const waitedSeconds = Math.round(AGENT_WAIT_TIMEOUT_MS / 1000); + const message = `Awaiting the agent timed out after ${waitedSeconds}s. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`; + return { + status: snapshot?.lifecycle ?? "idle", + permission: null, + lastMessage: message, + }; + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +export function startAgentRun( + agentManager: AgentManager, + agentId: string, + prompt: AgentPromptInput, + logger: Logger, + options?: StartAgentRunOptions, +): void { + const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); + const iterator = shouldReplace + ? agentManager.replaceAgentRun(agentId, prompt) + : agentManager.streamAgent(agentId, prompt); + void (async () => { + try { + for await (const _ of iterator) { + // Events are broadcast via AgentManager subscribers. + } + } catch (error) { + logger.error({ err: error, agentId }, "Agent stream failed"); + } + })(); +} + +export function sanitizePermissionRequest( + permission: AgentPermissionRequest | null | undefined, +): AgentPermissionRequest | null { + if (!permission) { + return null; + } + const sanitized: AgentPermissionRequest = { ...permission }; + if (sanitized.title === undefined) { + delete sanitized.title; + } + if (sanitized.description === undefined) { + delete sanitized.description; + } + if (sanitized.input === undefined) { + delete sanitized.input; + } + if (sanitized.suggestions === undefined) { + delete sanitized.suggestions; + } + if (sanitized.actions === undefined) { + delete sanitized.actions; + } + if (sanitized.metadata === undefined) { + delete sanitized.metadata; + } + return sanitized; +} + +export async function resolveAgentTitle( + agentStorage: AgentStorage, + agentId: string, + logger: Logger, +): Promise { + try { + const record = await agentStorage.get(agentId); + return record?.title ?? null; + } catch (error) { + logger.error({ err: error, agentId }, "Failed to load agent title"); + return null; + } +} + +export async function serializeSnapshotWithMetadata( + agentStorage: AgentStorage, + snapshot: ManagedAgent, + logger: Logger, +) { + const title = await resolveAgentTitle(agentStorage, snapshot.id, logger); + return serializeAgentSnapshot(snapshot, { title }); +} + +export function parseDurationString(input: string): number { + const trimmed = input.trim(); + if (/^\d+$/.test(trimmed)) { + return Number.parseInt(trimmed, 10) * 1000; + } + + let totalMs = 0; + let hasMatch = false; + const regex = /(\d+)([smh])/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(trimmed)) !== null) { + hasMatch = true; + const value = Number.parseInt(match[1], 10); + switch (match[2]) { + case "s": + totalMs += value * 1000; + break; + case "m": + totalMs += value * 60 * 1000; + break; + case "h": + totalMs += value * 60 * 60 * 1000; + break; + } + } + + if (!hasMatch) { + throw new Error( + `Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`, + ); + } + + return totalMs; +} + +export function toScheduleSummary(schedule: z.infer) { + const { runs: _runs, ...summary } = schedule; + return summary; +} diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index a0a5dddf8..e8a027ae1 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -20,6 +20,7 @@ export type CliConfigOverrides = Partial<{ listen: string; relayEnabled: boolean; mcpEnabled: boolean; + mcpInjectIntoAgents: boolean; allowedHosts: AllowedHostsConfig; }>; @@ -65,7 +66,9 @@ export function loadConfig( options?.cli?.allowedHosts, ]); - const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? false; + const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true; + const mcpInjectIntoAgents = + options?.cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? true; const relayEnabled = options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true; @@ -100,6 +103,7 @@ export function loadConfig( ), allowedHosts, mcpEnabled, + mcpInjectIntoAgents, mcpDebug: env.MCP_DEBUG === "1", agentStoragePath: path.join(paseoHome, "agents"), staticDir: "public", diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 87f104963..0af7b61a4 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -40,6 +40,9 @@ async function main() { if (process.argv.includes("--no-mcp")) { config.mcpEnabled = false; } + if (process.argv.includes("--no-inject-mcp")) { + config.mcpInjectIntoAgents = false; + } const installExitHook = () => { if (exitHookInstalled || !shutdownPromise) { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 7302fb472..34b9515e8 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -126,8 +126,9 @@ export const PersistedConfigSchema = z mcp: z .object({ enabled: z.boolean().optional(), + injectIntoAgents: z.boolean().optional(), }) - .strict() + .passthrough() .optional(), cors: z .object({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8d9807bfa..07ca2a656 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -65,11 +65,10 @@ import { extractTimestamps, } from "./persistence-hooks.js"; import { experimental_createMCPClient } from "ai"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; -export type AgentMcpTransportFactory = () => Promise; import { buildProviderRegistry } from "./agent/provider-registry.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { AgentManager } from "./agent/agent-manager.js"; @@ -102,7 +101,6 @@ import type { AgentPromptContentBlock, AgentPromptInput, AgentRunOptions, - McpServerConfig, AgentSessionConfig, AgentStreamEvent, AgentProvider, @@ -132,7 +130,6 @@ import { createPersistedWorkspaceRecord, } from "./workspace-registry.js"; import { - buildVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt, wrapSpokenInput, @@ -374,12 +371,10 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round( PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS, ); const AgentIdSchema = z.string().uuid(); -const VOICE_MCP_SERVER_NAME = "paseo_voice"; const VOICE_INTERRUPT_CONFIRMATION_MS = 500; type VoiceModeBaseConfig = { systemPrompt?: string; - mcpServers?: Record; }; interface AudioBufferState { @@ -420,13 +415,12 @@ export type SessionOptions = { loopService: LoopService; checkoutDiffManager: CheckoutDiffManager; backgroundGitFetchManager: BackgroundGitFetchManager; - createAgentMcpTransport: AgentMcpTransportFactory; + mcpBaseUrl?: string | null; stt: Resolvable; tts: Resolvable; terminalManager: TerminalManager | null; providerSnapshotManager?: ProviderSnapshotManager; voice?: { - voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable; }; voiceBridge?: { @@ -434,8 +428,6 @@ export type SessionOptions = { unregisterVoiceSpeakHandler?: (agentId: string) => void; registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void; unregisterVoiceCallerContext?: (agentId: string) => void; - ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise; - removeVoiceMcpSocketForAgent?: (agentId: string) => Promise; }; dictation?: { finalTimeoutMs?: number; @@ -605,7 +597,7 @@ export class Session { private readonly loopService: LoopService; private readonly checkoutDiffManager: CheckoutDiffManager; private readonly backgroundGitFetchManager: BackgroundGitFetchManager; - private readonly createAgentMcpTransport: AgentMcpTransportFactory; + private readonly mcpBaseUrl: string | null; private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; private readonly providerRegistry: ReturnType; @@ -634,7 +626,6 @@ export class Session { private readonly checkoutDiffSubscriptions = new Map void>(); private readonly workspaceGitWatchTargets = new Map(); private readonly workspaceGitFetchSubscriptions = new Map void>(); - private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; private readonly registerVoiceSpeakHandler?: ( agentId: string, handler: VoiceSpeakHandler, @@ -645,8 +636,6 @@ export class Session { context: VoiceCallerContext, ) => void; private readonly unregisterVoiceCallerContext?: (agentId: string) => void; - private readonly ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise; - private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise; private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot; private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private voiceModeAgentId: string | null = null; @@ -672,7 +661,7 @@ export class Session { loopService, checkoutDiffManager, backgroundGitFetchManager, - createAgentMcpTransport, + mcpBaseUrl, stt, tts, terminalManager, @@ -700,7 +689,7 @@ export class Session { this.loopService = loopService; this.checkoutDiffManager = checkoutDiffManager; this.backgroundGitFetchManager = backgroundGitFetchManager; - this.createAgentMcpTransport = createAgentMcpTransport; + this.mcpBaseUrl = mcpBaseUrl ?? null; this.terminalManager = terminalManager; this.providerSnapshotManager = providerSnapshotManager ?? null; if (this.terminalManager) { @@ -728,14 +717,11 @@ export class Session { this.providerSnapshotManager?.off("change", handleProviderSnapshotChange); }; } - this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null; this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null); this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler; this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler; this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext; this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext; - this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent; - this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent; this.getSpeechReadiness = dictation?.getSpeechReadiness; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.abortController = new AbortController(); @@ -940,12 +926,15 @@ export class Session { } /** - * Initialize Agent MCP client for this session using in-memory transport + * Initialize Agent MCP client for this session using the daemon's HTTP MCP endpoint. */ private async initializeAgentMcp(): Promise { try { - // Create an in-memory transport connected to the Agent MCP server - const transport = await this.createAgentMcpTransport(); + if (!this.mcpBaseUrl) { + this.sessionLogger.info("Skipping Agent MCP initialization because no MCP base URL is configured"); + return; + } + const transport = new StreamableHTTPClientTransport(new URL(this.mcpBaseUrl)); this.agentMcpClient = await experimental_createMCPClient({ transport, @@ -2615,41 +2604,8 @@ export class Session { return parsed.data; } - private cloneMcpServers( - servers: Record | undefined, - ): Record | undefined { - if (!servers) { - return undefined; - } - return JSON.parse(JSON.stringify(servers)) as Record; - } - - private buildVoiceModeMcpServers( - existing: Record | undefined, - socketPath: string, - ): Record { - const mcpStdio = this.voiceAgentMcpStdio; - if (!mcpStdio) { - throw new Error("Voice MCP stdio bridge is not configured"); - } - return { - ...(existing ?? {}), - [VOICE_MCP_SERVER_NAME]: buildVoiceAgentMcpServerConfig({ - command: mcpStdio.command, - baseArgs: mcpStdio.baseArgs, - socketPath, - env: mcpStdio.env, - }), - }; - } - private async enableVoiceModeForAgent(agentId: string): Promise { const startedAt = Date.now(); - const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent; - if (!ensureVoiceSocket) { - throw new Error("Voice MCP socket bridge is not configured"); - } - this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start"); const existing = await this.ensureAgentLoaded(agentId); this.sessionLogger.info( @@ -2657,22 +2613,14 @@ export class Session { "enableVoiceModeForAgent.ensureAgentLoaded.done", ); - this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureVoiceSocket.start"); - const socketPath = await ensureVoiceSocket(agentId); - this.sessionLogger.info( - { agentId, socketPath, elapsedMs: Date.now() - startedAt }, - "enableVoiceModeForAgent.ensureVoiceSocket.done", - ); this.registerVoiceBridgeForAgent(agentId); const baseConfig: VoiceModeBaseConfig = { systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt), - mcpServers: this.cloneMcpServers(existing.config.mcpServers), }; this.voiceModeBaseConfig = baseConfig; const refreshOverrides: Partial = { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true), - mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath), }; try { @@ -2689,7 +2637,6 @@ export class Session { } catch (error) { this.unregisterVoiceSpeakHandler?.(agentId); this.unregisterVoiceCallerContext?.(agentId); - await this.removeVoiceMcpSocketForAgent?.(agentId).catch(() => undefined); this.voiceModeBaseConfig = null; throw error; } @@ -2706,19 +2653,12 @@ export class Session { this.unregisterVoiceSpeakHandler?.(agentId); this.unregisterVoiceCallerContext?.(agentId); - await this.removeVoiceMcpSocketForAgent?.(agentId).catch((error) => { - this.sessionLogger.warn( - { err: error, agentId }, - "Failed to remove voice MCP socket bridge on disable", - ); - }); if (restoreAgentConfig && this.voiceModeBaseConfig) { const baseConfig = this.voiceModeBaseConfig; try { await this.agentManager.reloadAgentSession(agentId, { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false), - mcpServers: this.cloneMcpServers(baseConfig.mcpServers), }); } catch (error) { this.sessionLogger.warn( diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index db704bcd0..a0de6ad20 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -182,9 +182,7 @@ function createSessionForWorkspaceGitWatchTests(): { dispose: () => {}, } as any, backgroundGitFetchManager: backgroundGitFetchManager as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: null, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index eb6d331fc..6a541b0b1 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -123,9 +123,7 @@ function createSessionForWorkspaceTests(options: { appVersion?: string | null } }), dispose: () => {}, } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: null, @@ -230,9 +228,7 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: null, @@ -377,9 +373,7 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: { @@ -542,9 +536,7 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: { @@ -678,9 +670,7 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: { @@ -949,9 +939,7 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: null, diff --git a/packages/server/src/server/voice-mcp-bridge-command.test.ts b/packages/server/src/server/voice-mcp-bridge-command.test.ts deleted file mode 100644 index 9866e8cf9..000000000 --- a/packages/server/src/server/voice-mcp-bridge-command.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { describe, expect, test } from "vitest"; - -import { - resolveVoiceMcpBridgeFromRuntime, - resolveVoiceMcpBridgeScriptPath, -} from "./voice-mcp-bridge-command.js"; - -describe("resolveVoiceMcpBridgeFromRuntime", () => { - const bootstrapModuleUrl = new URL("./bootstrap.ts", import.meta.url).toString(); - - test("resolves default JS bridge script with node execPath", () => { - const result = resolveVoiceMcpBridgeFromRuntime({ - bootstrapModuleUrl, - execPath: "/usr/local/bin/node", - }); - - const expectedScriptPath = fileURLToPath( - new URL("../../scripts/mcp-stdio-socket-bridge-cli.mjs", bootstrapModuleUrl), - ); - - expect(result.source).toBe("default-js-script"); - expect(result.resolved.command).toBe("/usr/local/bin/node"); - expect(result.resolved.baseArgs).toEqual([expectedScriptPath]); - }); - - test("uses explicit script override when provided", () => { - const explicitScriptPath = fileURLToPath( - new URL("../../scripts/mcp-stdio-socket-bridge-cli.mjs", bootstrapModuleUrl), - ); - - const result = resolveVoiceMcpBridgeFromRuntime({ - bootstrapModuleUrl, - execPath: "/usr/local/bin/node", - explicitScriptPath, - }); - - expect(result.source).toBe("explicit-js-script"); - expect(result.resolved.command).toBe("/usr/local/bin/node"); - expect(result.resolved.baseArgs).toEqual([explicitScriptPath]); - }); - - test("throws when explicit script path is missing", () => { - expect(() => - resolveVoiceMcpBridgeScriptPath({ - bootstrapModuleUrl, - explicitScriptPath: "/tmp/does-not-exist-voice-bridge-script.mjs", - }), - ).toThrow("MCP stdio-socket bridge script not found"); - }); -}); diff --git a/packages/server/src/server/voice-mcp-bridge-command.ts b/packages/server/src/server/voice-mcp-bridge-command.ts deleted file mode 100644 index a71c5e0c0..000000000 --- a/packages/server/src/server/voice-mcp-bridge-command.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; - -export type VoiceMcpBridgeCommand = { command: string; baseArgs: string[] }; - -const DEFAULT_BRIDGE_SCRIPT_RELATIVE_URL = "../../scripts/mcp-stdio-socket-bridge-cli.mjs"; - -export function resolveVoiceMcpBridgeScriptPath(params: { - bootstrapModuleUrl: string; - explicitScriptPath?: string | null; -}): string { - const explicitScriptPath = params.explicitScriptPath?.trim(); - if (explicitScriptPath) { - if (!existsSync(explicitScriptPath)) { - throw new Error( - `MCP stdio-socket bridge script not found at configured path: ${explicitScriptPath}`, - ); - } - return explicitScriptPath; - } - - const scriptPath = fileURLToPath( - new URL(DEFAULT_BRIDGE_SCRIPT_RELATIVE_URL, params.bootstrapModuleUrl), - ); - if (!existsSync(scriptPath)) { - throw new Error(`MCP stdio-socket bridge script not found: ${scriptPath}`); - } - return scriptPath; -} - -export function resolveVoiceMcpBridgeFromRuntime(params: { - bootstrapModuleUrl: string; - execPath: string; - explicitScriptPath?: string | null; -}): { - resolved: VoiceMcpBridgeCommand; - source: string; -} { - const scriptPath = resolveVoiceMcpBridgeScriptPath({ - bootstrapModuleUrl: params.bootstrapModuleUrl, - explicitScriptPath: params.explicitScriptPath, - }); - return { - source: params.explicitScriptPath?.trim() ? "explicit-js-script" : "default-js-script", - resolved: { - command: params.execPath, - baseArgs: [scriptPath], - }, - }; -} diff --git a/packages/server/src/server/voice-mcp-bridge.test.ts b/packages/server/src/server/voice-mcp-bridge.test.ts deleted file mode 100644 index b54f96743..000000000 --- a/packages/server/src/server/voice-mcp-bridge.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { mkdtemp, rm } from "node:fs/promises"; -import { describe, expect, test } from "vitest"; -import { experimental_createMCPClient } from "ai"; -import { z } from "zod"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import pino from "pino"; - -import { createVoiceMcpSocketBridgeManager } from "./voice-mcp-bridge.js"; -import { resolveVoiceMcpBridgeScriptPath } from "./voice-mcp-bridge-command.js"; - -describe("voice MCP bridge", () => { - test("proxies stdio MCP bytes through per-agent unix socket bridge", async () => { - const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-voice-mcp-bridge-")); - const callerAgentId = "voice-agent-bridge-test"; - - const bridgeManager = createVoiceMcpSocketBridgeManager({ - runtimeDir: tmpRoot, - logger: pino({ level: "silent" }), - createAgentMcpServerForCaller: async (callerId) => { - const server = new McpServer({ - name: "bridge-test-server", - version: "1.0.0", - }); - - server.registerTool( - "echo_caller", - { - value: z.string().optional(), - }, - async (args) => { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - callerAgentId: callerId, - value: args.value ?? null, - }), - }, - ], - structuredContent: { - callerAgentId: callerId, - value: args.value ?? null, - }, - }; - }, - ); - - return server; - }, - }); - - const socketPath = await bridgeManager.ensureBridgeForCaller(callerAgentId); - - const transport = new StdioClientTransport({ - command: process.execPath, - args: [ - resolveVoiceMcpBridgeScriptPath({ - bootstrapModuleUrl: import.meta.url, - }), - "--socket", - socketPath, - ], - }); - - const client = await experimental_createMCPClient({ transport }); - - try { - const result = await client.callTool({ - name: "echo_caller", - args: { value: "ok" }, - }); - - const payload = - (result as { structuredContent?: { callerAgentId?: string; value?: string | null } }) - .structuredContent ?? null; - - expect(payload?.callerAgentId).toBe(callerAgentId); - } finally { - await client.close(); - await bridgeManager.stop(); - await rm(tmpRoot, { recursive: true, force: true }); - } - }, 30_000); -}); diff --git a/packages/server/src/server/voice-mcp-bridge.ts b/packages/server/src/server/voice-mcp-bridge.ts deleted file mode 100644 index cd7b958fd..000000000 --- a/packages/server/src/server/voice-mcp-bridge.ts +++ /dev/null @@ -1,145 +0,0 @@ -import net from "node:net"; -import path from "node:path"; -import { mkdir, rm } from "node:fs/promises"; -import type { Logger } from "pino"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; - -type BridgeServer = { - connect: (transport: StdioServerTransport) => Promise; - close?: () => Promise; -}; - -type BridgeEntry = { - socketPath: string; - server: net.Server; - sockets: Set; -}; - -export type VoiceMcpSocketBridgeManager = { - ensureBridgeForCaller: (callerAgentId: string) => Promise; - removeBridgeForCaller: (callerAgentId: string) => Promise; - stop: () => Promise; -}; - -function toSocketName(callerAgentId: string): string { - return `voice-mcp-${callerAgentId}.sock`; -} - -export function createVoiceMcpSocketBridgeManager(params: { - runtimeDir: string; - logger: Logger; - createAgentMcpServerForCaller: (callerAgentId: string) => Promise; -}): VoiceMcpSocketBridgeManager { - const logger = params.logger.child({ module: "voice-mcp-bridge" }); - const entries = new Map(); - const pendingCreates = new Map>(); - - const ensureBridgeForCaller = async (callerAgentId: string): Promise => { - const existing = entries.get(callerAgentId); - if (existing) { - return existing.socketPath; - } - - const pending = pendingCreates.get(callerAgentId); - if (pending) { - return pending; - } - - const createPromise = (async () => { - const socketPath = path.join(params.runtimeDir, toSocketName(callerAgentId)); - const sockets = new Set(); - const server = net.createServer((socket) => { - sockets.add(socket); - const connectionLogger = logger.child({ callerAgentId, component: "connection" }); - - let mcpServer: BridgeServer | null = null; - let transport: StdioServerTransport | null = null; - - const cleanup = async () => { - sockets.delete(socket); - await Promise.all([ - transport?.close().catch(() => undefined), - mcpServer?.close?.().catch(() => undefined), - ]); - }; - - socket.on("error", (error) => { - connectionLogger.error({ err: error }, "Voice MCP bridge socket error"); - }); - socket.on("close", () => { - void cleanup(); - }); - - void (async () => { - try { - mcpServer = await params.createAgentMcpServerForCaller(callerAgentId); - transport = new StdioServerTransport(socket, socket); - await mcpServer.connect(transport); - } catch (error) { - connectionLogger.error( - { err: error, callerAgentId }, - "Failed to initialize stream-level MCP bridge connection", - ); - socket.destroy(); - } - })(); - }); - - await mkdir(params.runtimeDir, { recursive: true }); - await rm(socketPath, { force: true }).catch(() => undefined); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(socketPath, () => { - server.off("error", reject); - resolve(); - }); - }); - - entries.set(callerAgentId, { socketPath, server, sockets }); - logger.info({ callerAgentId, socketPath }, "Voice MCP per-agent socket bridge listening"); - return socketPath; - })(); - - pendingCreates.set(callerAgentId, createPromise); - try { - return await createPromise; - } finally { - pendingCreates.delete(callerAgentId); - } - }; - - const removeBridgeForCaller = async (callerAgentId: string): Promise => { - const entry = entries.get(callerAgentId); - if (!entry) { - return; - } - entries.delete(callerAgentId); - - for (const socket of entry.sockets) { - socket.destroy(); - } - await new Promise((resolve, reject) => { - entry.server.close((error) => { - if (error) reject(error); - else resolve(); - }); - }); - await rm(entry.socketPath, { force: true }).catch(() => undefined); - logger.info({ callerAgentId, socketPath: entry.socketPath }, "Voice MCP socket bridge removed"); - }; - - const stop = async (): Promise => { - const activeCallerIds = Array.from(entries.keys()); - for (const callerAgentId of activeCallerIds) { - await removeBridgeForCaller(callerAgentId).catch((error) => { - logger.warn({ err: error, callerAgentId }, "Failed to stop voice MCP socket bridge"); - }); - } - }; - - return { - ensureBridgeForCaller, - removeBridgeForCaller, - stop, - }; -} diff --git a/packages/server/src/server/voice-types.ts b/packages/server/src/server/voice-types.ts index b5e75fa58..14c78ceb6 100644 --- a/packages/server/src/server/voice-types.ts +++ b/packages/server/src/server/voice-types.ts @@ -10,9 +10,3 @@ export type VoiceCallerContext = { allowCustomCwd?: boolean; enableVoiceTools?: boolean; }; - -export type VoiceMcpStdioConfig = { - command: string; - baseArgs: string[]; - env?: Record; -}; diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 5fe0502ee..25e9f00d6 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -74,13 +74,12 @@ function createServer(agentManagerOverrides?: Record) { {} as any, {} as any, "/tmp/paseo-test", - async () => ({}) as any, + null, { allowedOrigins: new Set() }, undefined, undefined, undefined, undefined, - undefined, "1.2.3-test", undefined, undefined, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index fe1b7c53b..c258af0fd 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -165,7 +165,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu {} as any, {} as any, "/tmp/paseo-test", - async () => ({}) as any, + null, { allowedOrigins: new Set() }, speechReadiness ? { @@ -176,7 +176,6 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu undefined, undefined, undefined, - undefined, TEST_DAEMON_VERSION, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 5780768e9..360a0dd3a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,6 +1,5 @@ import { WebSocketServer } from "ws"; import type { Server as HTTPServer } from "http"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { join } from "path"; import { hostname as getHostname } from "node:os"; import type { AgentManager } from "./agent/agent-manager.js"; @@ -37,7 +36,7 @@ import { buildProviderRegistry } from "./agent/provider-registry.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; -import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; +import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import { computeShouldNotifyClient, computeShouldSendPush, @@ -49,7 +48,6 @@ import { findLatestPermissionRequest, } from "../shared/agent-attention-notification.js"; -export type AgentMcpTransportFactory = () => Promise; export type ExternalSocketMetadata = { transport: "relay"; externalSessionKey?: string; @@ -243,17 +241,12 @@ export class VoiceAssistantWebSocketServer { private readonly paseoHome: string; private readonly pushTokenStore: PushTokenStore; private readonly pushService: PushService; - private readonly createAgentMcpTransport: AgentMcpTransportFactory; + private readonly mcpBaseUrl: string | null; private readonly speech: SpeechService | null; private readonly terminalManager: TerminalManager | null; private readonly dictation: { finalTimeoutMs?: number; } | null; - private readonly voice: { - voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; - ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise; - removeVoiceMcpSocketForAgent?: (agentId: string) => Promise; - } | null; private readonly voiceSpeakHandlers = new Map(); private readonly voiceCallerContexts = new Map(); private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; @@ -292,15 +285,10 @@ export class VoiceAssistantWebSocketServer { agentStorage: AgentStorage, downloadTokenStore: DownloadTokenStore, paseoHome: string, - createAgentMcpTransport: AgentMcpTransportFactory, + mcpBaseUrl: string | null, wsConfig: WebSocketServerConfig, speech?: SpeechService | null, terminalManager?: TerminalManager | null, - voice?: { - voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; - ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise; - removeVoiceMcpSocketForAgent?: (agentId: string) => Promise; - }, dictation?: { finalTimeoutMs?: number; }, @@ -345,10 +333,9 @@ export class VoiceAssistantWebSocketServer { }); this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; - this.createAgentMcpTransport = createAgentMcpTransport; + this.mcpBaseUrl = mcpBaseUrl; this.speech = speech ?? null; this.terminalManager = terminalManager ?? null; - this.voice = voice ?? null; this.dictation = dictation ?? null; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" }); @@ -650,13 +637,12 @@ export class VoiceAssistantWebSocketServer { scheduleService: this.scheduleService, checkoutDiffManager: this.checkoutDiffManager, backgroundGitFetchManager: this.backgroundGitFetchManager, - createAgentMcpTransport: this.createAgentMcpTransport, + mcpBaseUrl: this.mcpBaseUrl, stt: () => this.speech?.resolveStt() ?? null, tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, voice: { - ...(this.voice ?? {}), turnDetection: () => this.speech?.resolveTurnDetection() ?? null, }, voiceBridge: { @@ -672,8 +658,6 @@ export class VoiceAssistantWebSocketServer { unregisterVoiceCallerContext: (agentId) => { this.voiceCallerContexts.delete(agentId); }, - ensureVoiceMcpSocketForAgent: this.voice?.ensureVoiceMcpSocketForAgent, - removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent, }, dictation: this.dictation || this.speech