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
This commit is contained in:
Mohamed Boudra
2026-01-30 11:46:50 +07:00
parent eac342aa8b
commit 1670c7084b
6 changed files with 214 additions and 156 deletions

View File

@@ -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<string, string>;
labels: Record<string, string>;
};
type ManagedAgentWithSession = ManagedAgentBase & {
@@ -190,6 +192,7 @@ export class AgentManager {
private readonly idFactory: () => string;
private readonly registry?: AgentStorage;
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
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<string, string> }
): Promise<ManagedAgent> {
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<string, string> }
options?: { labels?: Record<string, string>; agentId?: string }
): Promise<AgentSessionConfig> {
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;

View File

@@ -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",
{

View File

@@ -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<void>((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<void>((resolve, reject) => {
const onError = (err: Error) => {
httpServer.off("listening", onListening);
@@ -456,10 +598,18 @@ export async function createPaseoDaemon(
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
// Clean up socket file
await new Promise<void>((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 {

View File

@@ -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]}`],

View File

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

View File

@@ -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,