From d0fcd69b32e8dab1daf9bde2a34649956ea5847f Mon Sep 17 00:00:00 2001 From: Nitay Rabinovich Date: Thu, 14 May 2026 15:21:59 +0200 Subject: [PATCH 1/2] Implement MCP agent registration model --- AGENTS.md | 1 + apps/server/drizzle/0003_mcp_agents.sql | 13 + apps/server/drizzle/meta/_journal.json | 7 + apps/server/src/app.ts | 215 ++++++++- apps/server/src/db/schema.ts | 9 +- apps/server/src/repository.ts | 429 +++++++++++++----- apps/server/test/app.test.ts | 167 ++++++- apps/server/test/env.test.ts | 2 +- apps/web/src/components/AgentPicker.tsx | 17 +- apps/web/src/components/AssistantChat.tsx | 15 +- apps/web/src/components/NarrowFallback.tsx | 98 +++- apps/web/src/components/NodeCard.tsx | 33 +- .../web/src/components/NodeSettingsButton.tsx | 16 +- apps/web/src/components/SessionList.tsx | 16 +- .../src/components/StandaloneAgentCard.tsx | 146 ++++++ apps/web/src/components/TopologyCanvas.tsx | 93 +++- apps/web/src/lib/agentRoles.ts | 57 +++ apps/web/src/lib/elkLayout.ts | 35 +- apps/web/src/routes/SessionsRoute.tsx | 34 +- apps/web/src/routes/TopologyRoute.tsx | 2 +- apps/web/src/styles.css | 25 + apps/web/test/app.test.tsx | 135 +++++- docs/mcp-agent-registration.md | 124 +++++ packages/protocol/src/index.ts | 88 +++- packages/protocol/test/index.test.ts | 58 +++ 25 files changed, 1650 insertions(+), 185 deletions(-) create mode 100644 apps/server/drizzle/0003_mcp_agents.sql create mode 100644 apps/web/src/components/StandaloneAgentCard.tsx create mode 100644 apps/web/src/lib/agentRoles.ts create mode 100644 docs/mcp-agent-registration.md diff --git a/AGENTS.md b/AGENTS.md index 1b0833b..76a200e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Decision docs: - [deployment shape](docs/deployment.md) - [ACP alias bridge](docs/acp-alias-bridge.md) +- [MCP agent registration](docs/mcp-agent-registration.md) Working rules: diff --git a/apps/server/drizzle/0003_mcp_agents.sql b/apps/server/drizzle/0003_mcp_agents.sql new file mode 100644 index 0000000..e0014c5 --- /dev/null +++ b/apps/server/drizzle/0003_mcp_agents.sql @@ -0,0 +1,13 @@ +ALTER TABLE `agents` ADD `host_kind` text NOT NULL DEFAULT 'custom'; +--> statement-breakpoint +ALTER TABLE `agents` ADD `execution_name` text; +--> statement-breakpoint +ALTER TABLE `agents` ADD `fingerprint` text; +--> statement-breakpoint +ALTER TABLE `agents` ADD `orchestrator` integer NOT NULL DEFAULT false; +--> statement-breakpoint +ALTER TABLE `agents` ADD `controlled` integer NOT NULL DEFAULT false; +--> statement-breakpoint +ALTER TABLE `agents` ADD `endpoints` text NOT NULL DEFAULT '[]'; +--> statement-breakpoint +CREATE INDEX `agents_node_execution_idx` ON `agents` (`node_id`,`host_kind`,`execution_name`); diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 587c043..52412b2 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1778675200000, "tag": "0002_session_cwd", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1778761600000, + "tag": "0003_mcp_agents", + "breakpoints": true } ] } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 6b2ecd4..3faeba5 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,10 +1,14 @@ import type { + AgentRecord, BrowserRealtimeEvent, BrowseNodeDirectoriesQuery, BrowseNodeDirectoriesResponse, CapabilitySyncPayload, CreateSessionRequest, InvocationRequestPayload, + McpAgentRegistrationRequest, + McpDelegateRequest, + McpEnsureNodeRequest, NodeDirectoryBrowsePayload, NodeDirectoryBrowseResultPayload, NodeHeartbeatPayload, @@ -28,6 +32,11 @@ import { capabilitySyncPayloadSchema, createSessionRequestSchema, invocationRequestPayloadSchema, + mcpAgentRegistrationRequestSchema, + mcpAgentRegistrationResponseSchema, + mcpDelegateRequestSchema, + mcpEnsureNodeRequestSchema, + mcpStatusResponseSchema, nodeDirectoryBrowsePayloadSchema, nodeDirectoryBrowseResultPayloadSchema, nodeHeartbeatPayloadSchema, @@ -121,6 +130,14 @@ type AppRouteDeps = { nodeLogs: NodeLogStore; }; +function readBearerToken(header: string | undefined) { + if (!header) { + return null; + } + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + return match?.[1] ?? null; +} + function websocketSend(socket: WebSocket, payload: unknown) { socket.send(JSON.stringify(payload)); } @@ -296,6 +313,19 @@ export function buildApp(options: AppOptions = {}) { nodeSocket.send(envelope); } + function controlledTransportAgentId(agent: AgentRecord) { + return typeof agent.capabilities.controlledAgentId === "string" + ? agent.capabilities.controlledAgentId + : agent.id; + } + + function resolveLogicalAgentId(nodeId: string, candidate: string | null) { + if (!candidate) { + return null; + } + return repository.findAgentByControlledAgentId(nodeId, candidate)?.id ?? candidate; + } + function bindSocket(socket: WebSocket, role: Role | null, nodeId: string | null) { if (role === "browser") { browserSockets.add(socket); @@ -447,12 +477,34 @@ export function buildApp(options: AppOptions = {}) { } case "session.event": { const payload = sessionEventSchema.parse(envelope.payload) as SessionEventRecord; + const sourceAgentId = resolveLogicalAgentId(boundNodeId, payload.sourceAgentId); + const targetAgentId = resolveLogicalAgentId(boundNodeId, payload.targetAgentId); + const eventPayload = + payload.eventType === "session.invocation.requested" + ? { + ...payload.payload, + sourceAgentId: + resolveLogicalAgentId( + boundNodeId, + typeof payload.payload.sourceAgentId === "string" + ? payload.payload.sourceAgentId + : null + ) ?? payload.payload.sourceAgentId, + targetAgentId: + resolveLogicalAgentId( + boundNodeId, + typeof payload.payload.targetAgentId === "string" + ? payload.payload.targetAgentId + : null + ) ?? payload.payload.targetAgentId + } + : payload.payload; repository.appendSessionEvent({ sessionId: payload.sessionId, eventType: payload.eventType, - sourceAgentId: payload.sourceAgentId, - targetAgentId: payload.targetAgentId, - payload: payload.payload + sourceAgentId, + targetAgentId, + payload: eventPayload }); if (payload.eventType === "session.output.completed") { repository.updateSessionStatus(payload.sessionId, "completed"); @@ -465,8 +517,13 @@ export function buildApp(options: AppOptions = {}) { } if (payload.eventType === "session.invocation.requested") { routeInvocation( - invocationRequestPayloadSchema.parse(payload.payload), - payload + invocationRequestPayloadSchema.parse(eventPayload), + { + ...payload, + sourceAgentId, + targetAgentId, + payload: eventPayload + } ); } maybePropagateChildCompletion(payload); @@ -496,6 +553,21 @@ export function buildApp(options: AppOptions = {}) { } function routeInvocation(payload: InvocationRequestPayload, parentEvent: SessionEventRecord) { + const sourceAgent = repository.findAgent(payload.sourceAgentId); + if (!sourceAgent || !sourceAgent.orchestrator) { + const denied = repository.appendSessionEvent({ + sessionId: payload.parentSessionId, + eventType: "session.invocation.denied", + sourceAgentId: payload.sourceAgentId, + targetAgentId: payload.targetAgentId, + payload: { + reason: "source_agent_cannot_orchestrate" + } + }); + void broadcastSession(denied.sessionId); + return; + } + const allowed = repository.canInvoke(payload.sourceAgentId, payload.targetAgentId); if (!allowed) { const denied = repository.appendSessionEvent({ @@ -522,14 +594,14 @@ export function buildApp(options: AppOptions = {}) { } const targetAgent = repository.findAgent(payload.targetAgentId); - if (!targetAgent) { + if (!targetAgent || !targetAgent.controlled || !targetAgent.nodeId) { repository.appendSessionEvent({ sessionId: payload.parentSessionId, eventType: "session.failed", sourceAgentId: payload.sourceAgentId, targetAgentId: payload.targetAgentId, payload: { - reason: "target_agent_missing" + reason: "target_agent_not_controlled" } }); void broadcastSession(payload.parentSessionId); @@ -563,7 +635,7 @@ export function buildApp(options: AppOptions = {}) { target: targetAgent.nodeId, payload: sessionStartPayloadSchema.parse({ sessionId: childSession.id, - agentId: payload.targetAgentId, + agentId: controlledTransportAgentId(targetAgent), prompt: payload.prompt, initiator: "agent", cwd: childSession.cwd, @@ -674,6 +746,13 @@ function registerApiRoutes({ sendToNode, nodeLogs }: AppRouteDeps) { + async function requireMcpAuth(request: FastifyRequest, reply: FastifyReply) { + const token = readBearerToken(request.headers.authorization); + if (registrationToken && token !== registrationToken) { + return reply.code(401).send({ message: "invalid registration token" }); + } + } + app.get("/api/auth/session", async (request: FastifyRequest) => ({ authenticated: isAuthenticated(request.cookies[authConfig.cookieName]), username: authConfig.username @@ -749,8 +828,18 @@ function registerApiRoutes({ return state; }); - app.post("/api/trigger-rules", { preHandler: requireBrowserAuth }, async (request: FastifyRequest) => { + app.post("/api/trigger-rules", { preHandler: requireBrowserAuth }, async (request: FastifyRequest, reply: FastifyReply) => { const body = upsertTriggerRuleRequestSchema.parse(request.body) as UpsertTriggerRuleRequest; + const sourceAgent = repository.findAgent(body.sourceAgentId); + const targetAgent = repository.findAgent(body.targetAgentId); + if (!sourceAgent || !sourceAgent.orchestrator) { + reply.code(409); + return { message: "source agent must be orchestration-capable" }; + } + if (!targetAgent || !targetAgent.controlled) { + reply.code(409); + return { message: "target agent must be controllable" }; + } const rule = repository.upsertTriggerRule(body); await broadcastTopology(); return rule; @@ -773,6 +862,10 @@ function registerApiRoutes({ reply.code(404); return { message: "agent not found" }; } + if (!agent.controlled || !agent.nodeId) { + reply.code(409); + return { message: "agent is not controllable" }; + } if (agent.nodeId !== body.nodeId) { reply.code(400); return { message: "agent does not belong to node" }; @@ -812,7 +905,10 @@ function registerApiRoutes({ target: agent.nodeId, payload: sessionStartPayloadSchema.parse({ sessionId: session.id, - agentId: body.agentId, + agentId: + typeof agent.capabilities.controlledAgentId === "string" + ? agent.capabilities.controlledAgentId + : body.agentId, prompt: body.prompt, initiator: "user", cwd, @@ -836,6 +932,10 @@ function registerApiRoutes({ reply.code(404); return { message: "entry agent missing" }; } + if (!agent.controlled || !agent.nodeId) { + reply.code(409); + return { message: "entry agent is not controllable" }; + } repository.appendSessionEvent({ sessionId: state.session.id, @@ -855,7 +955,10 @@ function registerApiRoutes({ target: agent.nodeId, payload: sessionInputPayloadSchema.parse({ sessionId: state.session.id, - agentId: agent.id, + agentId: + typeof agent.capabilities.controlledAgentId === "string" + ? agent.capabilities.controlledAgentId + : agent.id, prompt: body.prompt }) }); @@ -875,6 +978,10 @@ function registerApiRoutes({ reply.code(404); return { message: "entry agent missing" }; } + if (!agent.controlled || !agent.nodeId) { + reply.code(409); + return { message: "entry agent is not controllable" }; + } repository.updateSessionStatus(state.session.id, "cancelled"); repository.appendSessionEvent({ @@ -895,7 +1002,10 @@ function registerApiRoutes({ target: agent.nodeId, payload: { sessionId: state.session.id, - agentId: agent.id, + agentId: + typeof agent.capabilities.controlledAgentId === "string" + ? agent.capabilities.controlledAgentId + : agent.id, reason: "user_cancelled" } }); @@ -903,6 +1013,87 @@ function registerApiRoutes({ await broadcastSession(state.session.id); return repository.getSession(state.session.id); }); + + app.post("/api/mcp/ensure-node", { preHandler: requireMcpAuth }, async (request: FastifyRequest) => { + const body = mcpEnsureNodeRequestSchema.parse(request.body) as McpEnsureNodeRequest; + return repository.ensureNode(body); + }); + + app.post("/api/mcp/register-self", { preHandler: requireMcpAuth }, async (request: FastifyRequest) => { + const body = mcpAgentRegistrationRequestSchema.parse(request.body) as McpAgentRegistrationRequest; + const result = repository.upsertMcpAgent(body); + await broadcastTopology(); + return mcpAgentRegistrationResponseSchema.parse(result); + }); + + app.get("/api/mcp/status/:agentId", { preHandler: requireMcpAuth }, async (request: FastifyRequest, reply: FastifyReply) => { + const params = request.params as { agentId: string }; + const result = repository.mcpStatus(params.agentId); + if (!result) { + reply.code(404); + return { message: "agent not found" }; + } + return mcpStatusResponseSchema.parse(result); + }); + + app.post("/api/mcp/delegate", { preHandler: requireMcpAuth }, async (request: FastifyRequest, reply: FastifyReply) => { + const body = mcpDelegateRequestSchema.parse(request.body) as McpDelegateRequest; + const sourceAgent = repository.findAgent(body.sourceAgentId); + if (!sourceAgent || !sourceAgent.orchestrator) { + reply.code(409); + return { message: "source agent is not orchestration-capable" }; + } + const targetAgent = repository.findAgent(body.targetAgentId); + if (!targetAgent || !targetAgent.controlled || !targetAgent.nodeId) { + reply.code(409); + return { message: "target agent is not controllable" }; + } + + const node = repository.findNode(targetAgent.nodeId); + if (!node || node.status !== "online" || !nodeSockets.has(targetAgent.nodeId)) { + reply.code(409); + return { message: "target node is offline" }; + } + + const session = repository.createLinkedSession({ + entryAgentId: targetAgent.id, + initiator: "agent", + cwd: body.cwd, + parentSessionId: null, + sourceAgentId: sourceAgent.id + }); + repository.updateSessionStatus(session.id, "running"); + repository.appendSessionEvent({ + sessionId: session.id, + eventType: "session.created", + sourceAgentId: sourceAgent.id, + targetAgentId: targetAgent.id, + payload: { + prompt: body.prompt + } + }); + + sendToNode(targetAgent.nodeId, { + type: "session.start", + requestId: nanoid(10), + sessionId: session.id, + source: "server", + target: targetAgent.nodeId, + payload: sessionStartPayloadSchema.parse({ + sessionId: session.id, + agentId: + typeof targetAgent.capabilities.controlledAgentId === "string" + ? targetAgent.capabilities.controlledAgentId + : targetAgent.id, + prompt: body.prompt, + initiator: "agent", + cwd: body.cwd, + parentSessionId: null + }) + }); + await broadcastSession(session.id); + return repository.getSession(session.id); + }); } function validateOnlineNodeAction( diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 10268dc..3e39c7a 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -15,12 +15,17 @@ export const nodesTable = sqliteTable("nodes", { export const agentsTable = sqliteTable("agents", { id: text("id").primaryKey(), nodeId: text("node_id") - .notNull() .references(() => nodesTable.id, { onDelete: "cascade" }), name: text("name").notNull(), backend: text("backend").notNull(), + hostKind: text("host_kind").notNull().default("custom"), + executionName: text("execution_name"), + fingerprint: text("fingerprint"), + orchestrator: integer("orchestrator", { mode: "boolean" }).notNull().default(false), + controlled: integer("controlled", { mode: "boolean" }).notNull().default(false), status: text("status").notNull(), - capabilities: text("capabilities").notNull() + capabilities: text("capabilities").notNull(), + endpoints: text("endpoints").notNull().default("[]") }); export const triggerRulesTable = sqliteTable("trigger_rules", { diff --git a/apps/server/src/repository.ts b/apps/server/src/repository.ts index c63579c..6e478b6 100644 --- a/apps/server/src/repository.ts +++ b/apps/server/src/repository.ts @@ -1,7 +1,11 @@ import type { AgentRecord, + AgentStatus, BrowserRealtimeEvent, Capability, + McpAgentRegistrationRequest, + McpAgentRegistrationResponse, + McpEnsureNodeRequest, NodeRecord, SessionEventRecord, SessionRecord, @@ -9,10 +13,17 @@ import type { TriggerRule, TriggerMode } from "@amesh/protocol"; -import { agentSchema, nodeSchema, sessionEventSchema, sessionSchema, triggerRuleSchema } from "@amesh/protocol"; +import { + agentSchema, + nodeSchema, + sessionEventSchema, + sessionSchema, + triggerRuleSchema +} from "@amesh/protocol"; import { and, asc, eq } from "drizzle-orm"; import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; import { nanoid } from "nanoid"; +import { createHash } from "node:crypto"; import { agentsTable, @@ -28,24 +39,112 @@ function parseJson(value: string): T { return JSON.parse(value) as T; } +function normalizeHostKind(value: string | null | undefined): AgentRecord["hostKind"] { + const normalized = (value ?? "").trim().toLowerCase(); + if (normalized === "codex" || normalized === "claude" || normalized === "gemini") { + return normalized; + } + return "custom"; +} + +function canonicalAgentId(input: { + nodeId: string | null; + hostKind: AgentRecord["hostKind"]; + executionName: string | null; + fingerprint: string | null; +}) { + const identityTail = + input.nodeId && input.executionName ? input.executionName : input.fingerprint ?? input.executionName ?? ""; + const raw = [ + input.nodeId ?? "global", + input.hostKind, + identityTail + ].join("|"); + return `agent_${createHash("sha1").update(raw).digest("hex").slice(0, 16)}`; +} + +function backendForRoles(orchestrator: boolean, controlled: boolean): AgentRecord["backend"] { + if (orchestrator && controlled) { + return "hybrid"; + } + if (orchestrator) { + return "mcp"; + } + return "acpx"; +} + export class Repository { constructor(private readonly db: Database) {} - registerNode(input: { id?: string; name: string; host: string; labels: string[] }) { - const node: NodeRecord = { - id: input.id ?? nanoid(10), - name: input.name, - host: input.host, - labels: input.labels, - paths: [], - status: "online", - registeredAt: new Date().toISOString(), - lastSeenAt: new Date().toISOString(), - version: null, - latestVersion: null, - updateRequired: false - }; + private parseNodeRow(row: typeof nodesTable.$inferSelect): NodeRecord { + return nodeSchema.parse({ + id: row.id, + name: row.name, + status: row.status, + host: row.host, + labels: parseJson(row.labels), + paths: parseJson(row.paths ?? "[]"), + registeredAt: row.registeredAt, + lastSeenAt: row.lastSeenAt ?? null + }); + } + private parseAgentRow(row: typeof agentsTable.$inferSelect): AgentRecord { + return agentSchema.parse({ + id: row.id, + nodeId: row.nodeId ?? null, + name: row.name, + backend: row.backend, + hostKind: normalizeHostKind(row.hostKind), + executionName: row.executionName ?? null, + fingerprint: row.fingerprint ?? null, + orchestrator: Boolean(row.orchestrator), + controlled: Boolean(row.controlled), + status: row.status, + capabilities: parseJson>(row.capabilities), + endpoints: parseJson }>>( + row.endpoints ?? "[]" + ) + }); + } + + private upsertAgentRecord(agent: AgentRecord) { + this.db + .insert(agentsTable) + .values({ + id: agent.id, + nodeId: agent.nodeId, + name: agent.name, + backend: agent.backend, + hostKind: agent.hostKind, + executionName: agent.executionName, + fingerprint: agent.fingerprint, + orchestrator: agent.orchestrator, + controlled: agent.controlled, + status: agent.status, + capabilities: JSON.stringify(agent.capabilities), + endpoints: JSON.stringify(agent.endpoints) + }) + .onConflictDoUpdate({ + target: agentsTable.id, + set: { + nodeId: agent.nodeId, + name: agent.name, + backend: agent.backend, + hostKind: agent.hostKind, + executionName: agent.executionName, + fingerprint: agent.fingerprint, + orchestrator: agent.orchestrator, + controlled: agent.controlled, + status: agent.status, + capabilities: JSON.stringify(agent.capabilities), + endpoints: JSON.stringify(agent.endpoints) + } + }) + .run(); + } + + private upsertNodeRow(node: NodeRecord, reconnectToken: string) { this.db .insert(nodesTable) .values({ @@ -54,7 +153,7 @@ export class Repository { host: node.host, labels: JSON.stringify(node.labels), paths: JSON.stringify(node.paths), - reconnectToken: nanoid(24), + reconnectToken, status: node.status, registeredAt: node.registeredAt, lastSeenAt: node.lastSeenAt @@ -65,12 +164,32 @@ export class Repository { name: node.name, host: node.host, labels: JSON.stringify(node.labels), - reconnectToken: nanoid(24), - status: "online", + paths: JSON.stringify(node.paths), + status: node.status, + registeredAt: node.registeredAt, lastSeenAt: node.lastSeenAt } }) .run(); + } + + registerNode(input: { id?: string; name: string; host: string; labels: string[] }) { + const existing = input.id ? this.findNode(input.id) : null; + const node: NodeRecord = { + id: input.id ?? nanoid(10), + name: input.name, + host: input.host, + labels: input.labels, + paths: [], + status: "online", + registeredAt: existing?.registeredAt ?? new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + version: null, + latestVersion: null, + updateRequired: false + }; + + this.upsertNodeRow(node, this.getReconnectToken(node.id) ?? nanoid(24)); return node; } @@ -96,16 +215,7 @@ export class Repository { return null; } - return nodeSchema.parse({ - id: row.id, - name: row.name, - status: row.status, - host: row.host, - labels: parseJson(row.labels), - paths: parseJson(row.paths ?? "[]"), - registeredAt: row.registeredAt, - lastSeenAt: row.lastSeenAt ?? null - }); + return this.parseNodeRow(row); } resumeNode(nodeId: string, reconnectToken: string, observedAt: string) { @@ -128,16 +238,7 @@ export class Repository { .where(eq(nodesTable.id, nodeId)) .run(); - return nodeSchema.parse({ - id: row.id, - name: row.name, - status: "online", - host: row.host, - labels: parseJson(row.labels), - paths: parseJson(row.paths ?? "[]"), - registeredAt: row.registeredAt, - lastSeenAt: observedAt - }); + return this.parseNodeRow({ ...row, status: "online", lastSeenAt: observedAt }); } markNodeOffline(nodeId: string) { @@ -146,11 +247,12 @@ export class Repository { .set({ status: "offline" }) .where(eq(nodesTable.id, nodeId)) .run(); - this.db - .update(agentsTable) - .set({ status: "offline" }) - .where(eq(agentsTable.nodeId, nodeId)) - .run(); + for (const agent of this.listTopology().agents.filter((entry) => entry.nodeId === nodeId)) { + this.upsertAgentRecord({ + ...agent, + status: agent.orchestrator ? agent.status : "offline" + }); + } } heartbeat(nodeId: string, observedAt: string) { @@ -174,80 +276,110 @@ export class Repository { .run(); } + ensureNode(input: McpEnsureNodeRequest): { node: NodeRecord; reconnectToken: string } { + const nodeId = input.id ?? nanoid(10); + const existing = this.findNode(nodeId); + const reconnectToken = this.getReconnectToken(nodeId) ?? nanoid(24); + const node: NodeRecord = { + id: nodeId, + name: input.name, + host: input.host, + labels: input.labels, + paths: existing?.paths ?? [], + status: existing?.status ?? "pending", + registeredAt: existing?.registeredAt ?? new Date().toISOString(), + lastSeenAt: existing?.lastSeenAt ?? null, + version: null, + latestVersion: null, + updateRequired: false + }; + + this.upsertNodeRow(node, reconnectToken); + return { node, reconnectToken }; + } + syncCapabilities(nodeId: string, capabilities: Capability[]) { - const existing = this.db - .select() - .from(agentsTable) - .where(eq(agentsTable.nodeId, nodeId)) - .all(); - const incomingIds = new Set(capabilities.map((capability) => capability.id)); + const existing = this.listTopology().agents.filter((agent) => agent.nodeId === nodeId); + const incomingControlledIds = new Set(capabilities.map((capability) => capability.id)); for (const capability of capabilities) { - this.db - .insert(agentsTable) - .values({ - id: capability.id, - nodeId, - name: capability.name, - backend: "acpx", - status: capability.status, - capabilities: JSON.stringify({ + const hostKind = normalizeHostKind(capability.acpxAgent); + const executionName = capability.acpxAgent.trim() || capability.name.trim() || null; + const canonicalId = canonicalAgentId({ + nodeId, + hostKind, + executionName, + fingerprint: null + }); + const previous = this.findAgent(canonicalId) ?? + existing.find( + (agent) => + agent.hostKind === hostKind && + agent.executionName === executionName + ) ?? + this.findAgent(capability.id); + const agentId = previous?.id ?? capability.id; + const endpoints = [ + ...(previous?.endpoints.filter((endpoint) => endpoint.transport !== "acp") ?? []), + { + transport: "acp" as const, + metadata: { + capabilityId: capability.id, acpxAgent: capability.acpxAgent, - error: capability.error, + command: capability.command, + args: capability.args, cwd: capability.cwd, - labels: capability.labels - }) - }) - .onConflictDoUpdate({ - target: agentsTable.id, - set: { - name: capability.name, - status: capability.status, - capabilities: JSON.stringify({ - acpxAgent: capability.acpxAgent, - error: capability.error, - cwd: capability.cwd, - labels: capability.labels - }) + labels: capability.labels, + error: capability.error } - }) - .run(); + } + ]; + const orchestrator = previous?.orchestrator ?? false; + const agent: AgentRecord = { + id: agentId, + nodeId, + name: capability.name, + backend: backendForRoles(orchestrator, true), + hostKind, + executionName, + fingerprint: previous?.fingerprint ?? null, + orchestrator, + controlled: true, + status: capability.status, + capabilities: { + acpxAgent: capability.acpxAgent, + controlledAgentId: capability.id, + error: capability.error, + cwd: capability.cwd, + labels: capability.labels + }, + endpoints + }; + this.upsertAgentRecord(agent); } - for (const row of existing) { - if (!incomingIds.has(row.id)) { - this.db - .update(agentsTable) - .set({ status: "offline" }) - .where(eq(agentsTable.id, row.id)) - .run(); + for (const agent of existing) { + const controlledAgentId = + typeof agent.capabilities.controlledAgentId === "string" + ? agent.capabilities.controlledAgentId + : null; + if (!controlledAgentId || incomingControlledIds.has(controlledAgentId)) { + continue; } + const endpoints = agent.endpoints.filter((endpoint) => endpoint.transport !== "acp"); + this.upsertAgentRecord({ + ...agent, + backend: backendForRoles(agent.orchestrator, false), + controlled: false, + status: agent.orchestrator ? agent.status : "offline", + endpoints + }); } } listTopology(): TopologySnapshot { - const nodes = this.db.select().from(nodesTable).all().map((row) => - nodeSchema.parse({ - id: row.id, - name: row.name, - status: row.status, - host: row.host, - labels: parseJson(row.labels), - paths: parseJson(row.paths ?? "[]"), - registeredAt: row.registeredAt, - lastSeenAt: row.lastSeenAt ?? null - }) - ); - const agents = this.db.select().from(agentsTable).all().map((row) => - agentSchema.parse({ - id: row.id, - nodeId: row.nodeId, - name: row.name, - backend: row.backend, - status: row.status, - capabilities: parseJson>(row.capabilities) - }) - ); + const nodes = this.db.select().from(nodesTable).all().map((row) => this.parseNodeRow(row)); + const agents = this.db.select().from(agentsTable).all().map((row) => this.parseAgentRow(row)); const triggerRules = this.db.select().from(triggerRulesTable).all().map((row) => triggerRuleSchema.parse({ id: row.id, @@ -450,14 +582,89 @@ export class Repository { return null; } - return agentSchema.parse({ - id: row.id, - nodeId: row.nodeId, - name: row.name, - backend: row.backend, - status: row.status, - capabilities: parseJson>(row.capabilities) + return this.parseAgentRow(row); + } + + findAgentByControlledAgentId(nodeId: string, controlledAgentId: string) { + return ( + this.listTopology().agents.find( + (agent) => + agent.nodeId === nodeId && + typeof agent.capabilities.controlledAgentId === "string" && + agent.capabilities.controlledAgentId === controlledAgentId + ) ?? null + ); + } + + upsertMcpAgent( + input: McpAgentRegistrationRequest + ): McpAgentRegistrationResponse { + const ensured = input.node ? this.ensureNode(input.node) : null; + const nodeId = ensured?.node.id ?? null; + const hostKind = input.hostKind; + const executionName = input.executionName?.trim() || null; + const fingerprint = input.fingerprint?.trim() || null; + const controlled = input.controlled && nodeId !== null; + const canonicalId = canonicalAgentId({ + nodeId, + hostKind, + executionName, + fingerprint }); + const previous = + this.findAgent(canonicalId) ?? + this.listTopology().agents.find( + (agent) => + agent.nodeId === nodeId && + agent.hostKind === hostKind && + agent.executionName === executionName + ) ?? + null; + const agentId = previous?.id ?? canonicalId; + const endpointTransport: "mcp-url" | "mcp-npx" = + input.transport === "url" ? "mcp-url" : "mcp-npx"; + const endpoints = [ + ...(previous?.endpoints.filter((endpoint) => endpoint.transport !== endpointTransport) ?? []), + { + transport: endpointTransport, + metadata: input.metadata + } + ]; + const agent: AgentRecord = { + id: agentId, + nodeId, + name: input.name, + backend: backendForRoles(true, Boolean(previous?.controlled || controlled)), + hostKind, + executionName, + fingerprint, + orchestrator: true, + controlled: Boolean(previous?.controlled || controlled), + status: "online", + capabilities: { + ...(previous?.capabilities ?? {}), + mcpTransport: input.transport + }, + endpoints + }; + + this.upsertAgentRecord(agent); + return { + agent, + node: ensured?.node ?? null, + reconnectToken: ensured?.reconnectToken ?? null + }; + } + + mcpStatus(agentId: string) { + const agent = this.findAgent(agentId); + if (!agent) { + return null; + } + return { + agent, + node: agent.nodeId ? this.findNode(agent.nodeId) : null + }; } sessionUpdatedEvent(sessionId: string): BrowserRealtimeEvent { diff --git a/apps/server/test/app.test.ts b/apps/server/test/app.test.ts index d34ffb6..cfe65ac 100644 --- a/apps/server/test/app.test.ts +++ b/apps/server/test/app.test.ts @@ -1,7 +1,7 @@ import { WebSocket } from "ws"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { AddressInfo } from "node:net"; -import { access, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, mkdtemp, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -171,11 +171,8 @@ describe("server app", () => { }); await isolatedApp.close(); - expect(dbFile).toContain("/apps/server/data/amesh.sqlite"); + expect(dbFile.replaceAll("\\", "/")).toContain("/apps/server/data/amesh.sqlite"); await access(dbFile); - await rm(dbFile, { force: true }); - await rm(`${dbFile}-shm`, { force: true }); - await rm(`${dbFile}-wal`, { force: true }); } finally { process.chdir(originalCwd); } @@ -476,6 +473,18 @@ describe("server app", () => { ); await waitForIdle(); + await registerMcpSelf(app, { + name: "A", + hostKind: "custom", + executionName: "a", + transport: "npx", + node: { + id: "node-1", + name: "a", + host: "a.example", + labels: [] + } + }); const create = await injectAuthed(app, authCookie, { method: "POST", @@ -547,6 +556,18 @@ describe("server app", () => { ); await waitForIdle(); + await registerMcpSelf(app, { + name: "Claude", + hostKind: "claude", + executionName: "claude", + transport: "npx", + node: { + id: "node-1", + name: "a", + host: "a.example", + labels: [] + } + }); const rule = await injectAuthed(app, authCookie, { method: "POST", @@ -822,6 +843,33 @@ describe("server app", () => { }); it("deletes trigger rules through the control-plane API", async () => { + const node1 = new WebSocket(`ws://${address}/ws?role=node&nodeId=node-1`); + const node2 = new WebSocket(`ws://${address}/ws?role=node&nodeId=node-2`); + await Promise.all([waitForOpen(node1), waitForOpen(node2)]); + node1.send(JSON.stringify(registerNode("node-1", "a"))); + node2.send(JSON.stringify(registerNode("node-2", "b"))); + await readNodeMessage(node1); + await readNodeMessage(node2); + node1.send( + JSON.stringify(syncCapabilities("node-1", [{ id: "agent-a", name: "A", acpxAgent: "a" }])) + ); + node2.send( + JSON.stringify(syncCapabilities("node-2", [{ id: "agent-b", name: "B", acpxAgent: "b" }])) + ); + await waitForIdle(); + await registerMcpSelf(app, { + name: "A", + hostKind: "custom", + executionName: "a", + transport: "npx", + node: { + id: "node-1", + name: "a", + host: "a.example", + labels: [] + } + }); + const create = await injectAuthed(app, authCookie, { method: "POST", url: "/api/trigger-rules", @@ -844,6 +892,90 @@ describe("server app", () => { url: "/api/trigger-rules" }); expect(rules.json()).toEqual([]); + node1.close(); + node2.close(); + }); + + it("registers an MCP npx agent onto an existing controlled agent and exposes delegate/status APIs", async () => { + const node = new WebSocket(`ws://${address}/ws?role=node&nodeId=node-1`); + await waitForOpen(node); + node.send(JSON.stringify(registerNode("node-1", "node-a"))); + await readNodeMessage(node); + node.send( + JSON.stringify(syncCapabilities("node-1", [{ id: "agent-a", name: "Codex", acpxAgent: "codex" }])) + ); + await waitForIdle(); + + const register = await app.inject({ + method: "POST", + url: "/api/mcp/register-self", + headers: { + authorization: "Bearer token" + }, + payload: { + name: "Codex", + hostKind: "codex", + executionName: "codex", + transport: "npx", + controlled: true, + node: { + id: "node-1", + name: "node-a", + host: "node-a.example", + labels: [] + } + } + }); + expect(register.statusCode).toBe(200); + expect(register.json().agent).toMatchObject({ + id: "agent-a", + orchestrator: true, + controlled: true, + nodeId: "node-1" + }); + + const status = await app.inject({ + method: "GET", + url: "/api/mcp/status/agent-a", + headers: { + authorization: "Bearer token" + } + }); + expect(status.statusCode).toBe(200); + expect(status.json()).toMatchObject({ + agent: { + id: "agent-a", + orchestrator: true, + controlled: true + }, + node: { + id: "node-1" + } + }); + + const delegateResponse = await app.inject({ + method: "POST", + url: "/api/mcp/delegate", + headers: { + authorization: "Bearer token" + }, + payload: { + sourceAgentId: "agent-a", + targetAgentId: "agent-a", + prompt: "delegate to yourself" + } + }); + expect(delegateResponse.statusCode).toBe(200); + + const startMessage = await readNodeMessage(node); + expect(startMessage).toMatchObject({ + type: "session.start", + payload: { + agentId: "agent-a", + initiator: "agent" + } + }); + node.close(); }); it("sends an update command to an online node through the admin API", async () => { @@ -1144,6 +1276,31 @@ async function loginCookie(app: ReturnType) { return String(cookie).split(";")[0]; } +async function registerMcpSelf( + app: ReturnType, + payload: { + name: string; + hostKind: string; + executionName: string; + transport: "url" | "npx"; + node?: { + id: string; + name: string; + host: string; + labels: string[]; + }; + } +) { + return await app.inject({ + method: "POST", + url: "/api/mcp/register-self", + headers: { + authorization: "Bearer token" + }, + payload + }); +} + async function injectAuthed( app: ReturnType, cookie: string, diff --git a/apps/server/test/env.test.ts b/apps/server/test/env.test.ts index f13eb35..1230576 100644 --- a/apps/server/test/env.test.ts +++ b/apps/server/test/env.test.ts @@ -4,7 +4,7 @@ import { serverEnvPaths } from "../src/env.js"; describe("serverEnvPaths", () => { it("points at the package-local env files", () => { - expect(serverEnvPaths()).toEqual([ + expect(serverEnvPaths().map((path) => path.replaceAll("\\", "/"))).toEqual([ expect.stringMatching(/apps\/server\/\.env$/), expect.stringMatching(/apps\/server\/\.env\.local$/) ]); diff --git a/apps/web/src/components/AgentPicker.tsx b/apps/web/src/components/AgentPicker.tsx index b023608..df5e62b 100644 --- a/apps/web/src/components/AgentPicker.tsx +++ b/apps/web/src/components/AgentPicker.tsx @@ -1,6 +1,7 @@ import type { AgentRecord } from "@amesh/protocol"; import { AgentAvatar } from "./AgentAvatar.js"; +import { agentCanLaunchSessions, agentRoleBadges } from "../lib/agentRoles.js"; type Props = { agents: AgentRecord[]; @@ -31,20 +32,21 @@ export function AgentPicker({ agents, nodeName, folderLabel, selectedAgentId, on

{nodeName - ? "Choose one of the agents exposed by this node. Folder selection is separate." + ? "Choose one of the controllable agents exposed by this node. Folder selection is separate." : "Pick a node to choose a launch target."}

{agents.length === 0 ? (
{nodeName - ? "No agents are exposed by this node yet." + ? "No controllable agents are exposed by this node yet." : "Pick a node to start a session."}
) : (
    {ranked.map((agent) => { - const disabled = agent.status !== "online"; + const disabled = agent.status !== "online" || !agentCanLaunchSessions(agent); + const roleBadges = agentRoleBadges(agent); return (
  • + {agentCanLaunchSessions(agent) ? null : ( + <> + {" "} + read-only from amesh + + )} +
  • +
+ + ))} ); } diff --git a/apps/web/src/components/NodeCard.tsx b/apps/web/src/components/NodeCard.tsx index f8953f5..1bb8f58 100644 --- a/apps/web/src/components/NodeCard.tsx +++ b/apps/web/src/components/NodeCard.tsx @@ -3,6 +3,13 @@ import { useNavigate } from "@tanstack/react-router"; import { ArrowRight } from "lucide-react"; import type { AgentRecord, AgentStatus, NodeRecord, NodeStatus } from "@amesh/protocol"; +import { + agentCanBeControlled, + agentCanLaunchSessions, + agentCanOrchestrate, + agentRoleBadges, + getAgentNodeId +} from "../lib/agentRoles.js"; import { relativeTime } from "../lib/time.js"; import { NodeSettingsButton } from "./NodeSettingsButton.js"; @@ -71,19 +78,35 @@ export function NodeCard({ data }: NodeCardProps) { ) : (
    {agents.map((agent) => { - const chatDisabled = isOffline || agent.status !== "online"; + const chatDisabled = isOffline || agent.status !== "online" || !agentCanLaunchSessions(agent); const connectionSelected = connectionSourceAgentId === agent.id; - const connectionDisabled = isOffline || agent.status !== "online"; + const canPickAsSource = agentCanOrchestrate(agent) && agent.status === "online" && !isOffline; + const canPickAsTarget = agentCanBeControlled(agent) && agent.status === "online" && !isOffline; + const connectionDisabled = connectionSourceAgentId + ? connectionSelected + ? false + : !canPickAsTarget + : !canPickAsSource; const connectionLabel = connectionSourceAgentId ? connectionSelected ? `Cancel connection from ${agent.name}` : `Connect ${connectionSourceAgentName ?? "selected agent"} to ${agent.name}` : `Start connection from ${agent.name}`; + const roleBadges = agentRoleBadges(agent); return (
  • {agent.name}
    {agent.id}
    + {roleBadges.length > 0 ? ( +
    + {roleBadges.map((badge) => ( + + {badge} + + ))} +
    + ) : null} {typeof agent.capabilities.cwd === "string" ? (
    {agent.capabilities.cwd}
    ) : null} @@ -118,7 +141,7 @@ export function NodeCard({ data }: NodeCardProps) { void navigate({ to: "/sessions", search: { - node: agent.nodeId, + node: getAgentNodeId(agent) ?? undefined, folder: undefined, agent: agent.id, session: undefined, @@ -174,13 +197,13 @@ export function NodeCard({ data }: NodeCardProps) { type="target" position={Position.Left} id={agent.id} - isConnectable={!isOffline} + isConnectable={!isOffline && agentCanBeControlled(agent)} />
  • ); diff --git a/apps/web/src/components/NodeSettingsButton.tsx b/apps/web/src/components/NodeSettingsButton.tsx index d18466d..97e7d4c 100644 --- a/apps/web/src/components/NodeSettingsButton.tsx +++ b/apps/web/src/components/NodeSettingsButton.tsx @@ -20,6 +20,7 @@ import { requestNodeUpdate, updateNodePaths } from "../api.js"; +import { agentCanLaunchSessions, agentRoleBadges, getAgentNodeId } from "../lib/agentRoles.js"; import { useTopology } from "../lib/topologyContext.js"; type Props = { @@ -446,7 +447,9 @@ export function NodeSettingsButton({ typeof agent.capabilities.error === "string" ? agent.capabilities.error : null; - const disabled = agent.status !== "online" || nodeOffline; + const disabled = + agent.status !== "online" || nodeOffline || !agentCanLaunchSessions(agent); + const roleBadges = agentRoleBadges(agent); return (
  • @@ -460,6 +463,15 @@ export function NodeSettingsButton({ ) : null}
    + {roleBadges.length > 0 ? ( +
    + {roleBadges.map((badge) => ( + + {badge} + + ))} +
    + ) : null} {errorDetail ? (
    {errorDetail}
    ) : null} @@ -476,7 +488,7 @@ export function NodeSettingsButton({ void navigate({ to: "/sessions", search: { - node: agent.nodeId, + node: getAgentNodeId(agent) ?? undefined, folder, agent: agent.id, session: undefined diff --git a/apps/web/src/components/SessionList.tsx b/apps/web/src/components/SessionList.tsx index 2a74ea4..ad2d882 100644 --- a/apps/web/src/components/SessionList.tsx +++ b/apps/web/src/components/SessionList.tsx @@ -12,6 +12,7 @@ type Props = { selectedFolder: string | null; selectedId: string | null; loading: boolean; + canCreateSession: boolean; onSelect: (id: string) => void; onSelectFolder: (folder: string | null) => void; onNew: () => void; @@ -50,6 +51,7 @@ export function SessionList({ selectedFolder, selectedId, loading, + canCreateSession, onSelect, onSelectFolder, onNew @@ -71,8 +73,14 @@ export function SessionList({ type="button" className="btn btn-secondary" onClick={onNew} - disabled={!selectedNode} - title={selectedNode ? `New session on ${selectedNode.name}` : "Pick a node first"} + disabled={!selectedNode || !canCreateSession} + title={ + !selectedNode + ? "Pick a node first" + : canCreateSession + ? `New session on ${selectedNode.name}` + : "This node has no controllable agents" + } > New @@ -107,7 +115,9 @@ export function SessionList({ {loading && sessions.length === 0 ? null : sorted.length === 0 ? (
    {selectedNode - ? `No sessions in this folder yet. Hit New to start one.` + ? canCreateSession + ? `No sessions in this folder yet. Hit New to start one.` + : "No controllable agents on this node yet." : "No sessions yet. Pick a node on the left."}
    ) : ( diff --git a/apps/web/src/components/StandaloneAgentCard.tsx b/apps/web/src/components/StandaloneAgentCard.tsx new file mode 100644 index 0000000..6f60704 --- /dev/null +++ b/apps/web/src/components/StandaloneAgentCard.tsx @@ -0,0 +1,146 @@ +import { Handle, Position } from "@xyflow/react"; +import { useNavigate } from "@tanstack/react-router"; +import { ArrowRight } from "lucide-react"; +import type { AgentRecord, AgentStatus } from "@amesh/protocol"; + +import { + agentCanBeControlled, + agentCanLaunchSessions, + agentCanOrchestrate, + agentRoleBadges, + agentSecondaryLabel +} from "../lib/agentRoles.js"; + +export type StandaloneAgentCardData = { + agent: AgentRecord; + connectionSourceAgentId: string | null; + connectionSourceAgentName: string | null; + onConnectionPick: (agent: AgentRecord) => void; +}; + +type StandaloneAgentCardProps = { + data: { data: StandaloneAgentCardData }; +}; + +function agentStatusLabel(status: AgentStatus) { + return status; +} + +export function StandaloneAgentCard({ data }: StandaloneAgentCardProps) { + const { agent, connectionSourceAgentId, connectionSourceAgentName, onConnectionPick } = data.data; + const navigate = useNavigate(); + const connectionSelected = connectionSourceAgentId === agent.id; + const secondaryLabel = agentSecondaryLabel(agent) ?? "orchestrator"; + const canPickAsSource = agentCanOrchestrate(agent) && agent.status === "online"; + const canPickAsTarget = agentCanBeControlled(agent) && agent.status === "online"; + const connectionDisabled = connectionSourceAgentId + ? connectionSelected + ? false + : !canPickAsTarget + : !canPickAsSource; + const connectionLabel = connectionSourceAgentId + ? connectionSelected + ? `Cancel connection from ${agent.name}` + : `Connect ${connectionSourceAgentName ?? "selected agent"} to ${agent.name}` + : `Start connection from ${agent.name}`; + const chatDisabled = !agentCanLaunchSessions(agent) || agent.status !== "online"; + const roleBadges = agentRoleBadges(agent); + + return ( +
    +
    +
    +

    {agent.name}

    +
    {secondaryLabel}
    +
    + {roleBadges.map((badge) => ( + + {badge} + + ))} +
    +
    +
    + + {agent.status} + +
    +
    + +
      +
    • +
      +
      External orchestration endpoint
      +
      {agent.id}
      + {typeof agent.capabilities.cwd === "string" ? ( +
      {agent.capabilities.cwd}
      + ) : null} +
      +
      + + + + {agentStatusLabel(agent.status)} + +
      + + + +
    • +
    +
    + ); +} diff --git a/apps/web/src/components/TopologyCanvas.tsx b/apps/web/src/components/TopologyCanvas.tsx index 8538b83..1b27617 100644 --- a/apps/web/src/components/TopologyCanvas.tsx +++ b/apps/web/src/components/TopologyCanvas.tsx @@ -17,8 +17,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TopologySnapshot, TriggerMode } from "@amesh/protocol"; import { createTriggerRule, deleteTriggerRule } from "../api.js"; +import { + agentCanBeControlled, + agentCanOrchestrate, + getAgentNodeId +} from "../lib/agentRoles.js"; import { layoutTopology } from "../lib/elkLayout.js"; import { NodeCard, type NodeCardData } from "./NodeCard.js"; +import { StandaloneAgentCard, type StandaloneAgentCardData } from "./StandaloneAgentCard.js"; import { TriggerEdge, type TriggerEdgeData } from "./TriggerEdge.js"; type Props = { @@ -26,11 +32,20 @@ type Props = { }; type NodeCardNode = Node<{ data: NodeCardData }, "nodeCard">; +type StandaloneAgentNode = Node<{ data: StandaloneAgentCardData }, "standaloneAgentCard">; +type TopologyCanvasNode = NodeCardNode | StandaloneAgentNode; type TriggerEdgeRecord = Edge<{ data: TriggerEdgeData }, "trigger">; -const nodeTypes = { nodeCard: NodeCard }; +const nodeTypes = { nodeCard: NodeCard, standaloneAgentCard: StandaloneAgentCard }; const edgeTypes = { trigger: TriggerEdge }; +function topologyCardIdForAgent(agent: TopologySnapshot["agents"][number]): string | null { + const nodeId = getAgentNodeId(agent); + if (nodeId) return nodeId; + if (agentCanOrchestrate(agent)) return `agent:${agent.id}`; + return null; +} + function edgeStyle(mode: TriggerMode) { if (mode === "deny") { return { @@ -50,7 +65,7 @@ export function TopologyCanvas(props: Props) { } function CanvasInner({ topology }: Props) { - const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const positionsRef = useRef>(new Map()); const [toast, setToast] = useState(null); @@ -75,6 +90,20 @@ function CanvasInner({ topology }: Props) { (rule) => rule.sourceAgentId === sourceAgentId && rule.targetAgentId === targetAgentId ); + const source = agentsById.get(sourceAgentId); + const target = agentsById.get(targetAgentId); + if (!source || !target) { + showToast("Agent is no longer available."); + return false; + } + if (!agentCanOrchestrate(source)) { + showToast(`${source.name} cannot originate trigger rules.`); + return false; + } + if (!agentCanBeControlled(target)) { + showToast(`${target.name} cannot receive delegated work.`); + return false; + } if (existing && existing.mode === "allow") { showToast("Already connected. Click the edge to change."); return false; @@ -92,12 +121,16 @@ function CanvasInner({ topology }: Props) { return false; } }, - [topology.triggerRules] + [agentsById, topology.triggerRules] ); const pickConnectionEndpoint = useCallback( async (agent: TopologySnapshot["agents"][number]) => { if (!connectionSourceAgentId) { + if (!agentCanOrchestrate(agent)) { + showToast(`${agent.name} cannot originate trigger rules.`); + return; + } setConnectionSourceAgentId(agent.id); showToast(`Pick a target for ${agent.name}.`); return; @@ -107,6 +140,10 @@ function CanvasInner({ topology }: Props) { showToast("Connection cancelled."); return; } + if (!agentCanBeControlled(agent)) { + showToast(`${agent.name} cannot receive delegated work.`); + return; + } const created = await createAllowRule(connectionSourceAgentId, agent.id); if (created) { setConnectionSourceAgentId(null); @@ -120,7 +157,14 @@ function CanvasInner({ topology }: Props) { let cancelled = false; async function sync() { - const unknown = topology.nodes.filter((node) => !positionsRef.current.has(node.id)); + const standaloneAgents = topology.agents.filter( + (agent) => getAgentNodeId(agent) === null && agentCanOrchestrate(agent) + ); + const livingCardIds = new Set([ + ...topology.nodes.map((node) => node.id), + ...standaloneAgents.map((agent) => `agent:${agent.id}`) + ]); + const unknown = [...livingCardIds].filter((id) => !positionsRef.current.has(id)); if (unknown.length > 0 || positionsRef.current.size === 0) { const computed = await layoutTopology(topology); if (cancelled) return; @@ -131,14 +175,13 @@ function CanvasInner({ topology }: Props) { } } - const livingIds = new Set(topology.nodes.map((n) => n.id)); for (const id of [...positionsRef.current.keys()]) { - if (!livingIds.has(id)) positionsRef.current.delete(id); + if (!livingCardIds.has(id)) positionsRef.current.delete(id); } const nextNodes: NodeCardNode[] = topology.nodes.map((node) => { const position = positionsRef.current.get(node.id) ?? { x: 0, y: 0 }; - const agents = topology.agents.filter((agent) => agent.nodeId === node.id); + const agents = topology.agents.filter((agent) => getAgentNodeId(agent) === node.id); return { id: node.id, type: "nodeCard", @@ -156,9 +199,28 @@ function CanvasInner({ topology }: Props) { }; }); + const nextStandaloneNodes: StandaloneAgentNode[] = standaloneAgents.map((agent) => { + const cardId = `agent:${agent.id}`; + const position = positionsRef.current.get(cardId) ?? { x: 0, y: 0 }; + return { + id: cardId, + type: "standaloneAgentCard", + position, + data: { + data: { + agent, + connectionSourceAgentId, + connectionSourceAgentName, + onConnectionPick: pickConnectionEndpoint + } + } as { data: StandaloneAgentCardData }, + draggable: true + }; + }); + setNodes((current) => { const indexed = new Map(current.map((n) => [n.id, n])); - return nextNodes.map((next) => { + return [...nextNodes, ...nextStandaloneNodes].map((next) => { const existing = indexed.get(next.id); if (!existing) return next; return { ...next, position: existing.position ?? next.position }; @@ -218,12 +280,15 @@ function CanvasInner({ topology }: Props) { onFlip: () => flip(rule.sourceAgentId, rule.targetAgentId, rule.mode), onRemove: () => remove(rule.id) }; + const sourceCardId = topologyCardIdForAgent(source); + const targetCardId = topologyCardIdForAgent(target); + if (!sourceCardId || !targetCardId) return null; const edge: TriggerEdgeRecord = { id: rule.id, type: "trigger", - source: source.nodeId, - target: target.nodeId, + source: sourceCardId, + target: targetCardId, sourceHandle: source.id, targetHandle: target.id, data: { data }, @@ -254,8 +319,12 @@ function CanvasInner({ topology }: Props) { const isValidConnection: IsValidConnection = useCallback((connection) => { if (!connection.sourceHandle || !connection.targetHandle) return false; - return connection.sourceHandle !== connection.targetHandle; - }, []); + if (connection.sourceHandle === connection.targetHandle) return false; + const source = agentsById.get(connection.sourceHandle); + const target = agentsById.get(connection.targetHandle); + if (!source || !target) return false; + return agentCanOrchestrate(source) && agentCanBeControlled(target); + }, [agentsById]); return ( <> diff --git a/apps/web/src/lib/agentRoles.ts b/apps/web/src/lib/agentRoles.ts new file mode 100644 index 0000000..e7a1d77 --- /dev/null +++ b/apps/web/src/lib/agentRoles.ts @@ -0,0 +1,57 @@ +import type { AgentRecord } from "@amesh/protocol"; + +type AgentRoleKey = "orchestrator" | "controlled"; + +type AgentWithRoles = AgentRecord & Partial> & { + hostKind?: string; + executionName?: string; + nodeId?: string | null; +}; + +function readBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +export function getAgentNodeId(agent: AgentRecord | null | undefined): string | null { + if (!agent) return null; + const nodeId = (agent as AgentWithRoles).nodeId; + return typeof nodeId === "string" && nodeId.length > 0 ? nodeId : null; +} + +export function agentCanOrchestrate(agent: AgentRecord | null | undefined): boolean { + if (!agent) return false; + const explicit = readBoolean((agent as AgentWithRoles).orchestrator); + if (explicit !== null) return explicit; + return true; +} + +export function agentCanBeControlled(agent: AgentRecord | null | undefined): boolean { + if (!agent) return false; + const explicit = readBoolean((agent as AgentWithRoles).controlled); + if (explicit !== null) return explicit; + return getAgentNodeId(agent) !== null; +} + +export function agentCanLaunchSessions(agent: AgentRecord | null | undefined): boolean { + return agentCanBeControlled(agent) && getAgentNodeId(agent) !== null; +} + +export function agentRoleBadges(agent: AgentRecord | null | undefined): string[] { + if (!agent) return []; + const badges: string[] = []; + if (agentCanOrchestrate(agent)) badges.push("Orch"); + if (agentCanLaunchSessions(agent)) badges.push("Ctrl"); + return badges; +} + +export function agentSecondaryLabel(agent: AgentRecord | null | undefined): string | null { + if (!agent) return null; + const withRoles = agent as AgentWithRoles; + if (typeof withRoles.executionName === "string" && withRoles.executionName.length > 0) { + return withRoles.executionName; + } + if (typeof withRoles.hostKind === "string" && withRoles.hostKind.length > 0) { + return withRoles.hostKind; + } + return null; +} diff --git a/apps/web/src/lib/elkLayout.ts b/apps/web/src/lib/elkLayout.ts index 0bdce3d..27d5701 100644 --- a/apps/web/src/lib/elkLayout.ts +++ b/apps/web/src/lib/elkLayout.ts @@ -1,6 +1,7 @@ import * as ElkModule from "elkjs/lib/elk.bundled.js"; import type { ElkNode } from "elkjs/lib/elk.bundled.js"; import type { TopologySnapshot } from "@amesh/protocol"; +import { agentCanOrchestrate, getAgentNodeId } from "./agentRoles.js"; type ElkInstance = { layout: (graph: ElkNode) => Promise }; type ElkCtor = new () => ElkInstance; @@ -25,19 +26,37 @@ export async function layoutTopology( ): Promise> { const agentsByNode = new Map(); for (const agent of snapshot.agents) { - agentsByNode.set(agent.nodeId, (agentsByNode.get(agent.nodeId) ?? 0) + 1); + const nodeId = getAgentNodeId(agent); + if (!nodeId) continue; + agentsByNode.set(nodeId, (agentsByNode.get(nodeId) ?? 0) + 1); } const agentToNode = new Map(); for (const agent of snapshot.agents) { - agentToNode.set(agent.id, agent.nodeId); + const nodeId = getAgentNodeId(agent); + if (nodeId) { + agentToNode.set(agent.id, nodeId); + continue; + } + if (agentCanOrchestrate(agent)) { + agentToNode.set(agent.id, `agent:${agent.id}`); + } } - const elkNodes: ElkNode[] = snapshot.nodes.map((node) => ({ - id: node.id, - width: NODE_WIDTH, - height: nodeHeight(agentsByNode.get(node.id) ?? 0) - })); + const elkNodes: ElkNode[] = [ + ...snapshot.nodes.map((node) => ({ + id: node.id, + width: NODE_WIDTH, + height: nodeHeight(agentsByNode.get(node.id) ?? 0) + })), + ...snapshot.agents + .filter((agent) => getAgentNodeId(agent) === null && agentCanOrchestrate(agent)) + .map((agent) => ({ + id: `agent:${agent.id}`, + width: NODE_WIDTH, + height: nodeHeight(1) + })) + ]; const edges = snapshot.triggerRules .map((rule) => ({ @@ -75,7 +94,7 @@ export async function layoutTopology( return positions; } catch { const positions = new Map(); - snapshot.nodes.forEach((node, index) => { + elkNodes.forEach((node, index) => { positions.set(node.id, { x: (index % 3) * 320, y: Math.floor(index / 3) * 280 }); }); return positions; diff --git a/apps/web/src/routes/SessionsRoute.tsx b/apps/web/src/routes/SessionsRoute.tsx index 1d11e9d..0054679 100644 --- a/apps/web/src/routes/SessionsRoute.tsx +++ b/apps/web/src/routes/SessionsRoute.tsx @@ -5,6 +5,7 @@ import { AgentPicker } from "../components/AgentPicker.js"; import { AssistantChat } from "../components/AssistantChat.js"; import { NodeRail } from "../components/NodeRail.js"; import { SessionList } from "../components/SessionList.js"; +import { agentCanLaunchSessions, getAgentNodeId } from "../lib/agentRoles.js"; import { useSessions } from "../lib/sessionsContext.js"; import { useTopology } from "../lib/topologyContext.js"; @@ -59,18 +60,26 @@ export function SessionsRoute() { const sessionEntryAgent = activeSession ? topology.agents.find((agent) => agent.id === activeSession.session.entryAgentId) ?? null : null; - const selectedNodeId = sessionEntryAgent?.nodeId ?? focusedNodeId ?? focusedAgent?.nodeId ?? null; + const selectedNodeId = + (sessionEntryAgent ? getAgentNodeId(sessionEntryAgent) : null) ?? + focusedNodeId ?? + (focusedAgent ? getAgentNodeId(focusedAgent) : null) ?? + null; const selectedNode = topology.nodes.find((node) => node.id === selectedNodeId) ?? null; const nodeAgents = useMemo( - () => topology.agents.filter((agent) => agent.nodeId === selectedNodeId), + () => topology.agents.filter((agent) => getAgentNodeId(agent) === selectedNodeId), [selectedNodeId, topology.agents] ); + const launchAgents = useMemo( + () => nodeAgents.filter((agent) => agentCanLaunchSessions(agent)), + [nodeAgents] + ); const agentCwds = useMemo( () => - nodeAgents + launchAgents .map((agent) => readAgentCwd(agent)) .filter((cwd): cwd is string => Boolean(cwd)), - [nodeAgents] + [launchAgents] ); const availableFolders = useMemo( () => (selectedNode ? collectNodeFolders(selectedNode.paths, agentCwds) : []), @@ -84,8 +93,8 @@ export function SessionsRoute() { const selectedAgent = sessionEntryAgent ?? focusedAgent ?? - nodeAgents.find((agent) => agent.status === "online") ?? - nodeAgents[0] ?? + launchAgents.find((agent) => agent.status === "online") ?? + launchAgents[0] ?? null; const activeAgent = sessionEntryAgent ?? selectedAgent; const currentFolderLabel = readFolderLabel(selectedFolder); @@ -111,7 +120,7 @@ export function SessionsRoute() { } return sessions.summaries.filter((session) => { const agent = agentsById.get(session.entryAgentId); - return agent?.nodeId === selectedNodeId && (session.cwd ?? null) === selectedFolder; + return getAgentNodeId(agent) === selectedNodeId && (session.cwd ?? null) === selectedFolder; }); }, [agentsById, selectedFolder, selectedNodeId, sessions.summaries]); @@ -156,6 +165,7 @@ export function SessionsRoute() { selectedFolder={selectedFolder} selectedId={activeSession?.session.id ?? null} loading={sessions.loading} + canCreateSession={launchAgents.length > 0} onSelect={navigateToSession} onSelectFolder={(folder) => { void sessions.selectSession(null); @@ -188,7 +198,7 @@ export function SessionsRoute() { /> ) : !activeAgent && !activeSession ? ( navigateToScope({ nodeId: selectedNodeId, @@ -217,7 +227,11 @@ export function SessionsRoute() { }) } scopeLabel={selectedNode ? `${selectedNode.name} ยท ${currentFolderLabel}` : currentFolderLabel} - sessionTarget={selectedNodeId ? { nodeId: selectedNodeId, cwd: selectedFolder } : null} + sessionTarget={ + selectedNodeId && activeAgent && agentCanLaunchSessions(activeAgent) + ? { nodeId: selectedNodeId, cwd: selectedFolder } + : null + } /> )} diff --git a/apps/web/src/routes/TopologyRoute.tsx b/apps/web/src/routes/TopologyRoute.tsx index a610000..09a2cb2 100644 --- a/apps/web/src/routes/TopologyRoute.tsx +++ b/apps/web/src/routes/TopologyRoute.tsx @@ -30,7 +30,7 @@ type Props = { export function TopologyRoute({ topology }: Props) { const isNarrow = useIsNarrow(); - if (topology.nodes.length === 0) { + if (topology.nodes.length === 0 && topology.agents.length === 0) { return (
    diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2ffe8c5..fbf43c4 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -716,6 +716,28 @@ button:focus { outline: none; } margin-top: 4px; word-break: break-all; } +.node-card__role-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} +.role-badge { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + border: 1px solid var(--c-line); + border-radius: var(--r-xs); + background: var(--c-surface); + color: var(--c-mute); + font: var(--t-label); + letter-spacing: 0.06em; + text-transform: uppercase; +} +.role-badge--inline { + margin-right: 4px; +} .node-card__agent-status { font: var(--t-label); letter-spacing: 0.07em; @@ -741,6 +763,9 @@ button:focus { outline: none; } color: var(--c-mute); font-style: italic; } +.standalone-agent-card .node-card__header { + border-bottom-color: var(--c-accent-line); +} .node-settings__trigger { display: inline-grid; diff --git a/apps/web/test/app.test.tsx b/apps/web/test/app.test.tsx index b8873e6..46f8891 100644 --- a/apps/web/test/app.test.tsx +++ b/apps/web/test/app.test.tsx @@ -14,12 +14,16 @@ class MockSocket { const socket = new MockSocket(); type TestAgent = { id: string; - nodeId: string; + nodeId: string | null; name: string; - backend: "acpx"; + backend: string; status: string; + orchestrator?: boolean; + controlled?: boolean; + hostKind?: string; + executionName?: string; capabilities: { - acpxAgent: string; + acpxAgent?: string; cwd?: string; error?: string; }; @@ -323,6 +327,7 @@ describe("App shell", () => { it("loads the registration token into the empty-state install command", async () => { topologyNodes = []; + topologyAgents = []; window.history.pushState({}, "", "/"); render(); @@ -332,6 +337,32 @@ describe("App shell", () => { ); }); + it("surfaces orchestrator-only agents in topology even when no nodes exist", async () => { + narrowLayout = true; + topologyNodes = []; + topologyAgents = [ + { + id: "agent-remote", + nodeId: null, + name: "Claude", + backend: "mcp", + status: "online", + orchestrator: true, + controlled: false, + hostKind: "claude", + executionName: "claude", + capabilities: {} + } + ]; + window.history.pushState({}, "", "/"); + render(); + + await waitFor(() => expect(screen.getByText("Claude")).toBeTruthy()); + expect(screen.queryByText(/the mesh is empty/i)).toBeNull(); + expect(screen.getByText("Orch")).toBeTruthy(); + expect(screen.queryByText("Ctrl")).toBeNull(); + }); + it("triggers a node update from the admin UI", async () => { narrowLayout = true; window.history.pushState({}, "", "/"); @@ -544,6 +575,76 @@ describe("App shell", () => { ); }); + it("prevents impossible trigger-rule connections in the visible UI", async () => { + topologyAgents = [ + { + id: "agent-source", + nodeId: null, + name: "Claude", + backend: "mcp", + status: "online", + orchestrator: true, + controlled: false, + hostKind: "claude", + capabilities: {} + }, + { + id: "agent-bad-target", + nodeId: null, + name: "Remote URL", + backend: "mcp", + status: "online", + orchestrator: true, + controlled: false, + hostKind: "custom", + capabilities: {} + }, + { + id: "agent-target", + nodeId: "node-1", + name: "Codex", + backend: "acpx", + status: "online", + orchestrator: false, + controlled: true, + capabilities: { + acpxAgent: "codex" + } + } + ]; + topologyNodes = [ + { + ...topologyNodes[0]!, + updateRequired: false, + version: "v0.1.1", + latestVersion: "v0.1.1" + } + ]; + narrowLayout = true; + window.history.pushState({}, "", "/"); + render(); + + await waitFor(() => + expect(screen.getByRole("button", { name: /start connection from claude/i })).toBeTruthy() + ); + fireEvent.click(screen.getByRole("button", { name: /start connection from claude/i })); + + const impossibleTarget = screen.getByRole("button", { name: /connect claude to remote url/i }); + expect(impossibleTarget.hasAttribute("disabled")).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: /connect claude to codex/i })); + + await waitFor(() => + expect(triggerRuleRequests).toEqual([ + { + sourceAgentId: "agent-source", + targetAgentId: "agent-target", + mode: "allow" + } + ]) + ); + }); + it("lets the sessions rail switch between exposed folders on a node", async () => { topologyAgents = [ { @@ -576,6 +677,34 @@ describe("App shell", () => { ); }); + it("disables new-session flows when a node has no controllable agents", async () => { + topologyAgents = [ + { + id: "agent-1", + nodeId: "node-1", + name: "Claude", + backend: "mcp", + status: "online", + orchestrator: true, + controlled: false, + hostKind: "claude", + capabilities: {} + } + ]; + topologyNodes = [ + { + ...topologyNodes[0]!, + paths: [] + } + ]; + window.history.pushState({}, "", "/sessions?node=node-1"); + render(); + + const newButton = (await screen.findByRole("button", { name: /^new$/i })) as HTMLButtonElement; + expect(newButton.disabled).toBe(true); + expect(newButton.getAttribute("title")).toMatch(/pick a node first|no controllable agents/i); + }); + it("shows the current cwd even when only one folder variant exists", async () => { topologyAgents = [ { diff --git a/docs/mcp-agent-registration.md b/docs/mcp-agent-registration.md new file mode 100644 index 0000000..5ab1286 --- /dev/null +++ b/docs/mcp-agent-registration.md @@ -0,0 +1,124 @@ +# MCP agent registration + +## Decision + +- `amesh` will treat MCP as the inbound orchestration surface for now. +- MCP entries register logical agents in `amesh`. +- Agent roles are properties on one agent record: + - `orchestrator`: the agent can call `amesh` + - `controlled`: `amesh` can send work to the agent through a node-backed ACP adapter +- A node remains the durable runtime boundary for controlled agents. +- Automatic local ACP discovery is removed from the default bootstrap path. Detection can return later as an explicit node action. + +## Identity + +- A logical agent is not keyed by transport alone. +- MCP and ACP endpoints may map to the same logical agent when they refer to the same runnable agent identity on the same node. +- Two MCP installs on the same node are still different logical agents when the host agent identity differs. + - Example: the same `npx` command configured in Claude and Codex on one machine produces two agents on the same node. +- The canonical identity should include: + - `nodeId` when a node exists + - `hostKind`: `codex`, `claude`, `gemini`, `custom` + - `executionName` when known: `codex`, `claude`, etc. + - optional fingerprint for future hardening when config origin or executable path is available + +## Registration rules + +- `MCP URL` + - register the agent as `orchestrator=true` + - register the agent as `controlled=false` + - leave `nodeId=null` + - treat it as an external orchestrator that can talk to `amesh` but cannot be called back by `amesh` +- `MCP NPX` + - register the agent as `orchestrator=true` + - attempt to attach to or establish a durable local node service + - if node attach succeeds and the execution is ACP-compatible, mark the same logical agent as `controlled=true` + - if node attach fails, keep the agent as orchestrator-only instead of failing the MCP integration + +## Control model + +- MCP is the user-facing entrypoint. +- The durable node runtime remains an implementation detail behind MCP. +- The current Go daemon can stay behind that MCP entrypoint until there is a reason to replace it. +- `npx` launch does not imply controllability by itself. +- Control is granted only after successful node binding and ACP viability. + +## MCP tools + +- The MCP server should expose explicit tools even if it also performs a best-effort implicit registration flow. +- Initial tools: + - `register_self` + - `ensure_node` + - `status` + - `delegate` +- Implicit behavior is allowed for the happy path, but explicit tools remain the stable contract because host MCP environments do not expose identical metadata. + +## Data model impact + +- Nodes stay as first-class records. +- Agents need role fields instead of being treated as only ACP-backed node capabilities. +- A workable direction is: + - `orchestrator` boolean + - `controlled` boolean + - nullable `nodeId` + - identity fields for `hostKind` and `executionName` +- Transport-specific details should move out of the top-level agent identity and into endpoint metadata. +- An agent may have multiple endpoints over time: + - `mcp-url` + - `mcp-npx` + - `acp` + +## UI impact + +- The UI should show one logical agent card with role badges or capability indicators. +- Do not split the same logical identity into separate "MCP agent" and "ACP agent" rows. +- A node page can show: + - controlled agents bound to that node + - orchestrator-capable agents bound to that node +- Agents without a node still appear in the global topology as orchestrator-only. + +## Server impact + +- The control plane needs an MCP registration handshake in addition to the current node websocket handshake. +- Registration logic must resolve whether an MCP caller: + - creates a new orchestrator-only agent + - upgrades an existing logical agent to `controlled=true` + - binds to an existing node +- Session routing rules stay unchanged at the core: + - inbound delegation comes from orchestrator-capable agents + - outbound execution to a local agent still routes through a node and ACP + +## Node and daemon impact + +- Removing automatic discovery simplifies bootstrap: + - the node no longer needs to scan the machine by default + - controlled agents are registered intentionally through MCP-driven flows +- The durable runtime still has to keep: + - reconnect token and node identity + - websocket session + - ACP execution path + - health checks for controlled agents + - path exposure and session `cwd` handling +- The current Go daemon can remain responsible for that without being user-visible. + +## Protocol impact + +- `@amesh/protocol` currently models agents as node-backed ACP capabilities. +- That shape needs to expand so agents can exist without a node and can carry independent role flags. +- Capability sync from nodes should update only the controlled side of an agent. +- MCP registration should update the orchestrator side of an agent. + +## Risk + +- Identity matching will drift if it relies only on display names. +- MCP host metadata is inconsistent across clients, so implicit matching must remain best-effort. +- Merging node-controlled and MCP-orchestrating views into one logical agent is correct for the UI, but it requires a stricter server-side identity model than the current `agent.id` equals node capability id approach. +- Keeping fallback behavior matters: + - an `npx` MCP should still work as orchestrator-only when node establishment fails + +## Result + +- `amesh` gains a single user-facing MCP entrypoint without forcing every MCP-integrated agent to be controllable. +- URL-based MCP integrations become first-class orchestrators without nodes. +- `npx`-based MCP integrations can become both orchestrators and controlled agents when a node is available. +- The same machine can hold multiple logical agents on one node when they represent different host agents, such as Codex and Claude using the same `npx` package. diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 538a6b6..7bf65c3 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -6,6 +6,24 @@ export type NodeStatus = z.infer; export const agentStatusSchema = z.enum(["online", "offline", "error"]); export type AgentStatus = z.infer; +export const agentBackendSchema = z.enum(["acpx", "mcp", "hybrid"]); +export type AgentBackend = z.infer; + +export const agentHostKindSchema = z.enum([ + "codex", + "claude", + "gemini", + "custom" +]); +export type AgentHostKind = z.infer; + +export const agentEndpointTransportSchema = z.enum([ + "acp", + "mcp-url", + "mcp-npx" +]); +export type AgentEndpointTransport = z.infer; + export const triggerModeSchema = z.enum(["allow", "deny"]); export type TriggerMode = z.infer; @@ -41,11 +59,24 @@ export type NodeRecord = z.infer; export const agentSchema = z.object({ id: z.string(), - nodeId: z.string(), + nodeId: z.string().nullable().default(null), name: z.string(), - backend: z.literal("acpx"), + backend: agentBackendSchema, + hostKind: agentHostKindSchema, + executionName: z.string().nullable().default(null), + fingerprint: z.string().nullable().default(null), + orchestrator: z.boolean().default(false), + controlled: z.boolean().default(false), status: agentStatusSchema, - capabilities: payloadSchema + capabilities: payloadSchema, + endpoints: z + .array( + z.object({ + transport: agentEndpointTransportSchema, + metadata: payloadSchema.default({}) + }) + ) + .default([]) }); export type AgentRecord = z.infer; @@ -264,6 +295,57 @@ export const topologySnapshotSchema = z.object({ }); export type TopologySnapshot = z.infer; +export const mcpTransportSchema = z.enum(["url", "npx"]); +export type McpTransport = z.infer; + +export const mcpNodeBindingSchema = z.object({ + id: z.string().optional(), + name: z.string(), + host: z.string(), + labels: z.array(z.string()).default([]) +}); +export type McpNodeBinding = z.infer; + +export const mcpAgentRegistrationRequestSchema = z.object({ + name: z.string(), + hostKind: agentHostKindSchema, + executionName: z.string().optional(), + fingerprint: z.string().optional(), + transport: mcpTransportSchema, + controlled: z.boolean().default(false), + node: mcpNodeBindingSchema.optional(), + metadata: payloadSchema.default({}) +}); +export type McpAgentRegistrationRequest = z.infer< + typeof mcpAgentRegistrationRequestSchema +>; + +export const mcpEnsureNodeRequestSchema = mcpNodeBindingSchema; +export type McpEnsureNodeRequest = z.infer; + +export const mcpAgentRegistrationResponseSchema = z.object({ + agent: agentSchema, + node: nodeSchema.nullable().default(null), + reconnectToken: z.string().nullable().default(null) +}); +export type McpAgentRegistrationResponse = z.infer< + typeof mcpAgentRegistrationResponseSchema +>; + +export const mcpStatusResponseSchema = z.object({ + agent: agentSchema, + node: nodeSchema.nullable().default(null) +}); +export type McpStatusResponse = z.infer; + +export const mcpDelegateRequestSchema = z.object({ + sourceAgentId: z.string(), + targetAgentId: z.string(), + prompt: z.string(), + cwd: z.string().nullable().default(null) +}); +export type McpDelegateRequest = z.infer; + export const createSessionRequestSchema = z.object({ nodeId: z.string(), agentId: z.string(), diff --git a/packages/protocol/test/index.test.ts b/packages/protocol/test/index.test.ts index c7a49a9..1aa6357 100644 --- a/packages/protocol/test/index.test.ts +++ b/packages/protocol/test/index.test.ts @@ -4,6 +4,9 @@ import { capabilitySchema, browseNodeDirectoriesResponseSchema, browserRealtimeEventSchema, + mcpAgentRegistrationRequestSchema, + mcpAgentRegistrationResponseSchema, + mcpDelegateRequestSchema, nodeDirectoryBrowsePayloadSchema, parseProtocolEnvelope, protocolEnvelopeSchema, @@ -94,4 +97,59 @@ describe("protocol schema", () => { expect(payload.path).toBe(""); expect(response.entries[0]?.path).toBe("/srv/work/repo-a"); }); + + it("validates MCP registration payloads and responses", () => { + const request = mcpAgentRegistrationRequestSchema.parse({ + name: "Codex", + hostKind: "codex", + executionName: "codex", + transport: "npx", + controlled: true, + node: { + name: "node-a", + host: "node-a.example", + labels: [] + } + }); + const response = mcpAgentRegistrationResponseSchema.parse({ + agent: { + id: "agent-a", + nodeId: "node-1", + name: "Codex", + backend: "hybrid", + hostKind: "codex", + executionName: "codex", + fingerprint: null, + orchestrator: true, + controlled: true, + status: "online", + capabilities: {}, + endpoints: [] + }, + node: { + id: "node-1", + name: "node-a", + status: "pending", + host: "node-a.example", + labels: [], + paths: [], + registeredAt: new Date().toISOString(), + lastSeenAt: null + }, + reconnectToken: "token" + }); + + expect(request.node?.labels).toEqual([]); + expect(response.agent.orchestrator).toBe(true); + }); + + it("validates MCP delegate requests", () => { + const request = mcpDelegateRequestSchema.parse({ + sourceAgentId: "agent-source", + targetAgentId: "agent-target", + prompt: "delegate this" + }); + + expect(request.cwd).toBeNull(); + }); }); From cd6cc2c3e0a7baf46188477c1877e21a44c76190 Mon Sep 17 00:00:00 2001 From: Nitay Rabinovich Date: Thu, 14 May 2026 21:14:14 +0200 Subject: [PATCH 2/2] Fix MCP registration CI checks --- apps/server/scripts/smoke.ts | 38 ++++++++++++++++++++++++++++++++++ packages/protocol/src/index.ts | 7 ++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/server/scripts/smoke.ts b/apps/server/scripts/smoke.ts index beec4bd..851da74 100644 --- a/apps/server/scripts/smoke.ts +++ b/apps/server/scripts/smoke.ts @@ -41,6 +41,18 @@ async function main() { ); await idle(); + await registerMcpSelf(app, { + name: "Claude", + hostKind: "claude", + executionName: "claude", + transport: "npx", + node: { + id: "node-source", + name: "node-source", + host: "source-host", + labels: ["demo"] + } + }); const direct = await injectAuthed(app, authCookie, { method: "POST", @@ -280,6 +292,32 @@ async function loginCookie(app: ReturnType) { return String(cookie).split(";")[0]; } +async function registerMcpSelf( + app: ReturnType, + payload: { + name: string; + hostKind: string; + executionName: string; + transport: "url" | "npx"; + node?: { + id: string; + name: string; + host: string; + labels: string[]; + }; + } +) { + const response = await app.inject({ + method: "POST", + url: "/api/mcp/register-self", + headers: { + authorization: "Bearer demo-token" + }, + payload + }); + assert(response.statusCode === 200, "expected MCP self registration to succeed"); +} + async function injectAuthed( app: ReturnType, cookie: string, diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 7bf65c3..075b5a9 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -320,7 +320,12 @@ export type McpAgentRegistrationRequest = z.infer< typeof mcpAgentRegistrationRequestSchema >; -export const mcpEnsureNodeRequestSchema = mcpNodeBindingSchema; +export const mcpEnsureNodeRequestSchema = z.object({ + id: z.string().optional(), + name: z.string(), + host: z.string(), + labels: z.array(z.string()).default([]) +}); export type McpEnsureNodeRequest = z.infer; export const mcpAgentRegistrationResponseSchema = z.object({