From 1670c7084b5fcb5e5def8bf1fd258391ec2efea3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Jan 2026 11:46:50 +0700 Subject: [PATCH] fix: complete MCP split implementation - Fix socket path conflict: main server defaults to TCP (localhost:6767), Self-ID MCP uses Unix socket (self-id-mcp.sock) - Remove set_title/set_branch from mcp-server.ts (they belong only in agent-self-id-mcp.ts which is used by coding agents) - Rename mcpSocketPath to selfIdMcpSocketPath for consistency - Pass --socket arg to self-id-bridge so non-default paths work - Wire up PID lock to prevent multiple daemon instances - Add PASEO_SELF_ID_MCP_SOCK env var for testing override --- .../server/src/server/agent/agent-manager.ts | 38 ++++- .../server/src/server/agent/mcp-server.ts | 147 +---------------- packages/server/src/server/bootstrap.ts | 154 +++++++++++++++++- packages/server/src/server/config.ts | 25 ++- .../server/src/server/websocket-server.ts | 2 + .../src/server/websocket-session-bridge.ts | 4 + 6 files changed, 214 insertions(+), 156 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fa4a7c9b0..e5bdd1588 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -59,6 +59,8 @@ export type AgentManagerOptions = { registry?: AgentStorage; onAgentAttention?: AgentAttentionCallback; logger: Logger; + /** Path to the Self-ID MCP Unix socket for UI agent injection */ + selfIdMcpSocketPath?: string; }; export type WaitForAgentOptions = { @@ -106,7 +108,7 @@ type ManagedAgentBase = { /** * User-defined labels for categorizing agents (e.g., { ui: "true" }). */ - labels?: Record; + labels: Record; }; type ManagedAgentWithSession = ManagedAgentBase & { @@ -190,6 +192,7 @@ export class AgentManager { private readonly idFactory: () => string; private readonly registry?: AgentStorage; private readonly previousStatuses = new Map(); + private readonly selfIdMcpSocketPath?: string; private onAgentAttention?: AgentAttentionCallback; private logger: Logger; @@ -198,6 +201,7 @@ export class AgentManager { options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS; this.idFactory = options?.idFactory ?? (() => randomUUID()); this.registry = options?.registry; + this.selfIdMcpSocketPath = options?.selfIdMcpSocketPath; this.onAgentAttention = options?.onAgentAttention; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); if (options?.clients) { @@ -313,13 +317,18 @@ export class AgentManager { agentId?: string, options?: { labels?: Record } ): Promise { - const normalizedConfig = await this.normalizeConfig(config, { labels: options?.labels }); + // Generate agent ID early so we can use it in MCP config + const resolvedAgentId = agentId ?? this.idFactory(); + const normalizedConfig = await this.normalizeConfig(config, { + labels: options?.labels, + agentId: resolvedAgentId, + }); const client = this.requireClient(normalizedConfig.provider); const session = await client.createSession(normalizedConfig); return this.registerSession( session, normalizedConfig, - agentId ?? this.idFactory(), + resolvedAgentId, { labels: options?.labels } ); } @@ -835,7 +844,7 @@ export class AgentManager { lastUserMessageAt: options?.lastUserMessageAt ?? null, attention: { requiresAttention: false }, internal: config.internal ?? false, - labels: options?.labels, + labels: options?.labels ?? {}, } as ActiveManagedAgent; this.agents.set(agentId, managed); @@ -1103,7 +1112,7 @@ export class AgentManager { private async normalizeConfig( config: AgentSessionConfig, - options?: { labels?: Record } + options?: { labels?: Record; agentId?: string } ): Promise { const normalized: AgentSessionConfig = { ...config }; @@ -1117,12 +1126,29 @@ export class AgentManager { normalized.model = trimmed.length > 0 ? trimmed : undefined; } - // Inject paseoPromptInstructions for UI agents (with ui=true label) + // Inject paseoPromptInstructions and MCP config for UI agents (with ui=true label) const isUiAgent = options?.labels?.ui === "true"; if (isUiAgent) { normalized.paseoPromptInstructions = getSelfIdentificationInstructions({ cwd: normalized.cwd, }); + + // Inject Self-ID MCP server config (stdio bridge to self-id-mcp.sock) + if (this.selfIdMcpSocketPath && options?.agentId) { + const existingMcpServers = normalized.mcpServers ?? {}; + normalized.mcpServers = { + ...existingMcpServers, + "paseo-self-id": { + type: "stdio", + command: "paseo", + args: [ + "self-id-bridge", + "--socket", this.selfIdMcpSocketPath, + "--agent-id", options.agentId, + ], + }, + }; + } } return normalized; diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 153e44f50..afe8e05d9 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -25,13 +25,8 @@ 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 { - createWorktree, - isPaseoOwnedWorktreeCwd, - validateBranchSlug, -} from "../../utils/worktree.js"; +import { createWorktree } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; -import { NotGitRepoError, renameCurrentBranch } from "../../utils/checkout-git.js"; import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js"; export interface AgentMcpServerOptions { @@ -112,18 +107,6 @@ function expandPath(path: string): string { return resolve(path); } -type ToolErrorCode = "NOT_ALLOWED" | "NOT_GIT_REPO" | "INVALID_BRANCH"; - -class AgentMcpToolError extends Error { - readonly code: ToolErrorCode; - - constructor(code: ToolErrorCode, message: string) { - super(message); - this.name = "AgentMcpToolError"; - this.code = code; - } -} - /** * Wraps agentManager.waitForAgentEvent with a self-imposed timeout. * Returns a friendly message when timeout occurs, rather than letting @@ -903,134 +886,6 @@ export async function createAgentMcpServer( } ); - server.registerTool( - "set_title", - { - title: "Set Agent Title", - description: "Update the agent's title in the registry.", - inputSchema: { - title: z - .string() - .min(1) - .max(60) - .describe("Short descriptive title (<= 60 chars)."), - }, - outputSchema: { - success: z.boolean(), - title: z.string(), - }, - }, - async ({ title }) => { - if (!callerAgentId) { - throw new AgentMcpToolError( - "NOT_ALLOWED", - "set_title can only be called by a managed agent" - ); - } - - const agent = agentManager.getAgent(callerAgentId); - if (!agent) { - throw new Error(`Agent ${callerAgentId} not found`); - } - - const normalizedTitle = title.trim(); - if (!normalizedTitle) { - throw new AgentMcpToolError("NOT_ALLOWED", "Title cannot be empty"); - } - if (normalizedTitle.length > 60) { - throw new AgentMcpToolError( - "NOT_ALLOWED", - "Title must be 60 characters or fewer" - ); - } - - await agentManager.setTitle(agent.id, normalizedTitle); - return { - content: [], - structuredContent: ensureValidJson({ - success: true, - title: normalizedTitle, - }), - }; - } - ); - - server.registerTool( - "set_branch", - { - title: "Set Agent Branch", - description: - "Rename the current git branch. Allowed only inside Paseo-owned worktrees.", - inputSchema: { - name: z - .string() - .min(1) - .describe("Git branch name (lowercase letters, numbers, hyphens, slashes)."), - }, - outputSchema: { - success: z.boolean(), - branch: z.string(), - }, - }, - async ({ name }) => { - if (!callerAgentId) { - throw new AgentMcpToolError( - "NOT_ALLOWED", - "set_branch can only be called by a managed agent" - ); - } - - const agent = agentManager.getAgent(callerAgentId); - if (!agent) { - throw new Error(`Agent ${callerAgentId} not found`); - } - - const validation = validateBranchSlug(name); - if (!validation.valid) { - throw new AgentMcpToolError( - "INVALID_BRANCH", - validation.error ?? "Invalid branch name" - ); - } - - let ownership; - try { - ownership = await isPaseoOwnedWorktreeCwd(agent.cwd, { paseoHome: options.paseoHome }); - } catch (error) { - const notGitError = - error instanceof NotGitRepoError - ? error - : new NotGitRepoError(agent.cwd); - throw new AgentMcpToolError( - "NOT_GIT_REPO", - notGitError.message - ); - } - - if (!ownership.allowed) { - throw new AgentMcpToolError( - "NOT_ALLOWED", - "Branch renames are only allowed inside Paseo-owned worktrees" - ); - } - - const result = await renameCurrentBranch(agent.cwd, name); - if (result.currentBranch !== name) { - throw new Error( - `Branch rename failed (expected ${name}, got ${result.currentBranch ?? "unknown"})` - ); - } - - return { - content: [], - structuredContent: ensureValidJson({ - success: true, - branch: name, - }), - }; - } - ); - server.registerTool( "set_agent_mode", { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e7cc81443..a3f1cbc58 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -45,6 +45,7 @@ import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; +import { createAgentSelfIdMcpServer } from "./agent/agent-self-id-mcp.js"; import { createAllClients, shutdownProviders } from "./agent/provider-registry.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { @@ -54,6 +55,7 @@ import { } from "./connection-offer.js"; import { printPairingQrIfEnabled } from "./pairing-qr.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; +import { acquirePidLock, releasePidLock } from "./pid-lock.js"; import type { AgentClient, AgentProvider, @@ -70,6 +72,7 @@ export type PaseoOpenAIConfig = { export type PaseoDaemonConfig = { listen: string; paseoHome: string; + selfIdMcpSocketPath: string; corsAllowedOrigins: string[]; agentMcpRoute: string; agentMcpAllowedHosts: string[]; @@ -209,6 +212,7 @@ export async function createPaseoDaemon( ...config.agentClients, }, registry: agentStorage, + selfIdMcpSocketPath: config.selfIdMcpSocketPath, logger, }); @@ -221,6 +225,7 @@ export async function createPaseoDaemon( ); const agentMcpTransports: AgentMcpTransportMap = new Map(); + const selfIdMcpTransports: AgentMcpTransportMap = new Map(); const allowedHosts = config.agentMcpAllowedHosts; const createAgentMcpTransport = async (callerAgentId?: string) => { @@ -328,7 +333,118 @@ export async function createPaseoDaemon( app.post(agentMcpRoute, handleAgentMcpRequest); app.get(agentMcpRoute, handleAgentMcpRequest); app.delete(agentMcpRoute, handleAgentMcpRequest); - logger.info({ route: agentMcpRoute }, "Agent MCP server mounted"); + logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app"); + + // Create dedicated Self-ID MCP server on Unix socket for agent self-identification + // This only provides set_title and set_branch tools for coding agents + // Host validation is disabled since Unix sockets don't have HTTP hosts + const selfIdMcpSocketPath = config.selfIdMcpSocketPath; + + const createSelfIdMcpTransport = async (callerAgentId: string) => { + const selfIdMcpServer = await createAgentSelfIdMcpServer({ + agentManager, + paseoHome: config.paseoHome, + callerAgentId, + logger, + }); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + selfIdMcpTransports.set(sessionId, transport); + logger.debug({ sessionId, callerAgentId }, "Self-ID MCP session initialized"); + }, + onsessionclosed: (sessionId) => { + selfIdMcpTransports.delete(sessionId); + logger.debug({ sessionId }, "Self-ID MCP session closed"); + }, + // Disable host validation for Unix socket + enableDnsRebindingProtection: false, + }); + transport.onclose = () => { + if (transport.sessionId) { + selfIdMcpTransports.delete(transport.sessionId); + } + }; + transport.onerror = (err) => { + logger.error({ err }, "Self-ID MCP transport error"); + }; + + await selfIdMcpServer.connect(transport); + + return transport; + }; + + const handleSelfIdMcpRequest: express.RequestHandler = async (req, res) => { + if (config.mcpDebug) { + logger.debug( + { + method: req.method, + url: req.originalUrl, + sessionId: req.header("mcp-session-id"), + body: req.body, + }, + "Self-ID MCP request" + ); + } + try { + const sessionId = req.header("mcp-session-id"); + let transport = sessionId ? selfIdMcpTransports.get(sessionId) : undefined; + + if (!transport) { + if (req.method !== "POST") { + res.status(400).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Missing or invalid MCP session" }, + id: null, + }); + return; + } + if (!isInitializeRequest(req.body)) { + res.status(400).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Initialization request expected" }, + id: null, + }); + return; + } + const callerAgentIdRaw = req.query.callerAgentId; + const callerAgentId = + typeof callerAgentIdRaw === "string" + ? callerAgentIdRaw + : Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string" + ? callerAgentIdRaw[0] + : undefined; + if (!callerAgentId) { + res.status(400).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "callerAgentId query parameter is required for Self-ID MCP" }, + id: null, + }); + return; + } + transport = await createSelfIdMcpTransport(callerAgentId); + } + + await transport.handleRequest(req as any, res as any, req.body); + } catch (err) { + logger.error({ err }, "Failed to handle Self-ID MCP request"); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal MCP server error" }, + id: null, + }); + } + } + }; + + const selfIdMcpApp = express(); + selfIdMcpApp.use(express.json()); + selfIdMcpApp.post("/", handleSelfIdMcpRequest); + selfIdMcpApp.get("/", handleSelfIdMcpRequest); + selfIdMcpApp.delete("/", handleSelfIdMcpRequest); + const selfIdMcpSocketServer = createHTTPServer(selfIdMcpApp); let sttService: OpenAISTT | null = null; let ttsService: OpenAITTS | null = null; @@ -376,12 +492,38 @@ export async function createPaseoDaemon( downloadTokenStore, config.paseoHome, agentMcpRoute, + config.selfIdMcpSocketPath, { allowedOrigins }, { stt: sttService, tts: ttsService }, terminalManager ); const start = async () => { + // Acquire PID lock to prevent multiple daemon instances + await acquirePidLock(config.paseoHome, selfIdMcpSocketPath); + + // Start Self-ID MCP socket server first + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + selfIdMcpSocketServer.off("listening", onListening); + reject(err); + }; + const onListening = () => { + selfIdMcpSocketServer.off("error", onError); + logger.info({ path: selfIdMcpSocketPath }, `Self-ID MCP server listening on ${selfIdMcpSocketPath}`); + resolve(); + }; + selfIdMcpSocketServer.once("error", onError); + selfIdMcpSocketServer.once("listening", onListening); + + // Remove stale socket file if it exists + if (existsSync(selfIdMcpSocketPath)) { + unlinkSync(selfIdMcpSocketPath); + } + selfIdMcpSocketServer.listen(selfIdMcpSocketPath); + }); + + // Start main HTTP server await new Promise((resolve, reject) => { const onError = (err: Error) => { httpServer.off("listening", onListening); @@ -456,10 +598,18 @@ export async function createPaseoDaemon( await new Promise((resolve) => { httpServer.close(() => resolve()); }); - // Clean up socket file + await new Promise((resolve) => { + selfIdMcpSocketServer.close(() => resolve()); + }); + // Clean up socket files if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { unlinkSync(listenTarget.path); } + if (existsSync(selfIdMcpSocketPath)) { + unlinkSync(selfIdMcpSocketPath); + } + // Release PID lock + await releasePidLock(config.paseoHome); }; return { diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 8f4e234a0..8371c31ee 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -5,11 +5,25 @@ import type { STTConfig } from "./agent/stt-openai.js"; import type { TTSConfig } from "./agent/tts-openai.js"; import { loadPersistedConfig } from "./persisted-config.js"; -const DEFAULT_LISTEN = "127.0.0.1:6767"; +const DEFAULT_PORT = 6767; const DEFAULT_AGENT_MCP_ROUTE = "/mcp/agents"; const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; const DEFAULT_APP_BASE_URL = "https://app.paseo.sh"; +function getDefaultListen(): string { + // Main HTTP server defaults to TCP + return `127.0.0.1:${DEFAULT_PORT}`; +} + +function getSelfIdMcpSocketPath(paseoHome: string, env: NodeJS.ProcessEnv): string { + // Allow override via PASEO_SELF_ID_MCP_SOCK for testing + if (env.PASEO_SELF_ID_MCP_SOCK) { + return env.PASEO_SELF_ID_MCP_SOCK; + } + // Default to ${PASEO_HOME}/self-id-mcp.sock + return path.join(paseoHome, "self-id-mcp.sock"); +} + function parseOpenAIConfig(env: NodeJS.ProcessEnv) { const apiKey = env.OPENAI_API_KEY; if (!apiKey) return undefined; @@ -66,8 +80,14 @@ export function loadConfig( ): PaseoDaemonConfig { const persisted = loadPersistedConfig(paseoHome); - const listen = env.PASEO_LISTEN ?? persisted.listen ?? DEFAULT_LISTEN; + // PASEO_LISTEN can be: + // - host:port (TCP) + // - /path/to/socket (Unix socket) + // - unix:///path/to/socket (Unix socket) + // Default is TCP at 127.0.0.1:6767 + const listen = env.PASEO_LISTEN ?? persisted.listen ?? getDefaultListen(); const mcpListen = getListenForMcp(listen); + const selfIdMcpSocketPath = getSelfIdMcpSocketPath(paseoHome, env); const envCorsOrigins = env.PASEO_CORS_ORIGINS ? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim()) @@ -76,6 +96,7 @@ export function loadConfig( return { listen, paseoHome, + selfIdMcpSocketPath, corsAllowedOrigins: [...persisted.cors.allowedOrigins, ...envCorsOrigins], agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE, agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`], diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 3b86b9010..3755b45fd 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -30,6 +30,7 @@ export class VoiceAssistantWebSocketServer { downloadTokenStore: DownloadTokenStore, paseoHome: string, agentMcpRoute: string, + selfIdMcpSocketPath: string, wsConfig: WebSocketServerConfig, speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null }, terminalManager?: TerminalManager | null @@ -42,6 +43,7 @@ export class VoiceAssistantWebSocketServer { downloadTokenStore, paseoHome, agentMcpRoute, + selfIdMcpSocketPath, speech, terminalManager ); diff --git a/packages/server/src/server/websocket-session-bridge.ts b/packages/server/src/server/websocket-session-bridge.ts index 9cd7a1267..c4a4c112a 100644 --- a/packages/server/src/server/websocket-session-bridge.ts +++ b/packages/server/src/server/websocket-session-bridge.ts @@ -30,6 +30,7 @@ export class WebSocketSessionBridge { private readonly pushTokenStore: PushTokenStore; private readonly pushService: PushService; private readonly agentMcpRoute: string; + private readonly selfIdMcpSocketPath: string; private readonly stt: OpenAISTT | null; private readonly tts: OpenAITTS | null; private readonly terminalManager: TerminalManager | null; @@ -42,6 +43,7 @@ export class WebSocketSessionBridge { downloadTokenStore: DownloadTokenStore, paseoHome: string, agentMcpRoute: string, + selfIdMcpSocketPath: string, speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null }, terminalManager?: TerminalManager | null ) { @@ -51,6 +53,7 @@ export class WebSocketSessionBridge { this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.agentMcpRoute = agentMcpRoute; + this.selfIdMcpSocketPath = selfIdMcpSocketPath; this.stt = speech?.stt ?? null; this.tts = speech?.tts ?? null; this.terminalManager = terminalManager ?? null; @@ -87,6 +90,7 @@ export class WebSocketSessionBridge { this.agentManager, this.agentStorage, this.agentMcpRoute, + this.selfIdMcpSocketPath, this.stt, this.tts, this.terminalManager,