diff --git a/AGENTS.md b/AGENTS.md index 1b0833b..d011c39 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) +- [agent control MCP](docs/agent-control-mcp.md) Working rules: diff --git a/apps/server/package.json b/apps/server/package.json index f85b369..c7234f3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,12 +14,16 @@ }, "dependencies": { "@amesh/protocol": "workspace:*", + "@cfworker/json-schema": "^4.1.1", "@fastify/cookie": "^11.0.2", + "@modelcontextprotocol/node": "2.0.0-alpha.2", + "@modelcontextprotocol/server": "2.0.0-alpha.2", "better-sqlite3": "^12.2.0", "dotenv": "^17.4.2", "drizzle-orm": "^0.44.5", "fastify": "^5.6.0", - "nanoid": "^5.1.6" + "nanoid": "^5.1.6", + "zod": "^4.4.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 6b2ecd4..565e13b 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -45,8 +45,10 @@ import { upsertTriggerRuleRequestSchema } from "@amesh/protocol"; import cookie from "@fastify/cookie"; +import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; import { nanoid } from "nanoid"; +import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, extname, join, normalize, resolve, sep } from "node:path"; @@ -61,6 +63,7 @@ import { verifySession } from "./auth.js"; import { createDatabase } from "./db/client.js"; +import { buildAmeshMcpServer, type McpScope } from "./mcp.js"; import { Repository } from "./repository.js"; type Role = "browser" | "node"; @@ -80,6 +83,11 @@ type NodeSocket = { send: (message: ProtocolEnvelope) => void; }; +type McpSessionState = { + scope: McpScope; + transport: NodeStreamableHTTPServerTransport; +}; + class NodeLogStore { private readonly entries = new Map(); @@ -104,6 +112,7 @@ type AppRouteDeps = { registrationToken: string; repository: Repository; nodeSockets: Map; + mcpSessions: Map; pendingDirectoryBrowses: Map< string, { @@ -146,6 +155,7 @@ export function buildApp(options: AppOptions = {}) { const nodeSockets = new Map(); const nodeVersions = new Map(); const nodeLogs = new NodeLogStore(); + const mcpSessions = new Map(); const pendingDirectoryBrowses = new Map< string, { @@ -593,6 +603,7 @@ export function buildApp(options: AppOptions = {}) { registrationToken, repository, nodeSockets, + mcpSessions, pendingDirectoryBrowses, isAuthenticated, requireBrowserAuth, @@ -624,6 +635,10 @@ export function buildApp(options: AppOptions = {}) { }); app.addHook("onClose", async () => { + for (const state of mcpSessions.values()) { + await state.transport.close(); + } + mcpSessions.clear(); for (const socket of websocketServer.clients) { socket.close(); } @@ -665,6 +680,7 @@ function registerApiRoutes({ registrationToken, repository, nodeSockets, + mcpSessions, pendingDirectoryBrowses, isAuthenticated, requireBrowserAuth, @@ -674,6 +690,164 @@ function registerApiRoutes({ sendToNode, nodeLogs }: AppRouteDeps) { + async function authenticateMcpRequest(request: FastifyRequest, reply: FastifyReply) { + const origin = request.headers.origin; + if (origin) { + let parsedOrigin: URL; + try { + parsedOrigin = new URL(origin); + } catch { + reply.code(400).send({ message: "invalid origin header" }); + return null; + } + if (parsedOrigin.host !== request.headers.host) { + reply.code(403).send({ message: "origin not allowed" }); + return null; + } + } + + const cookieSession = verifySession(authConfig, request.cookies[authConfig.cookieName]); + if (cookieSession) { + return { + authMode: "browser_session" as const + }; + } + + const authorization = request.headers.authorization ?? ""; + const [scheme, token] = authorization.split(" ", 2); + if (scheme === "Bearer" && typeof token === "string") { + if (constantTimeStringEqual(token, authConfig.password)) { + return { authMode: "admin_password" as const }; + } + if (registrationToken && constantTimeStringEqual(token, registrationToken)) { + return { authMode: "registration_token" as const }; + } + } + + reply + .code(401) + .header("WWW-Authenticate", 'Bearer realm="amesh-mcp"') + .send({ message: "authentication required" }); + return null; + } + + function scopedMcpAgent(request: FastifyRequest, reply: FastifyReply, auth: { authMode: McpScope["authMode"] }) { + const scopedAgentId = typeof request.headers["x-amesh-agent-id"] === "string" + ? request.headers["x-amesh-agent-id"].trim() + : ""; + const scopedNodeId = typeof request.headers["x-amesh-node-id"] === "string" + ? request.headers["x-amesh-node-id"].trim() + : ""; + + if (!scopedAgentId) { + return { + authMode: auth.authMode, + scopedAgentId: null, + scopedNodeId: null + } satisfies McpScope; + } + + const agent = repository.findAgent(scopedAgentId); + if (!agent) { + reply.code(404).send({ message: `scoped agent not found: ${scopedAgentId}` }); + return null; + } + if (scopedNodeId && scopedNodeId !== agent.nodeId) { + reply.code(400).send({ message: "scoped node does not match scoped agent" }); + return null; + } + + return { + authMode: auth.authMode, + scopedAgentId: agent.id, + scopedNodeId: agent.nodeId + } satisfies McpScope; + } + + function isInitializeRequest(body: unknown) { + if (!body || typeof body !== "object") { + return false; + } + const candidate = body as { method?: unknown }; + return candidate.method === "initialize"; + } + + async function handleMcpRequest(request: FastifyRequest, reply: FastifyReply) { + const auth = await authenticateMcpRequest(request, reply); + if (!auth) { + return; + } + + const requestedSessionId = typeof request.headers["mcp-session-id"] === "string" + ? request.headers["mcp-session-id"] + : null; + + let sessionState = requestedSessionId ? mcpSessions.get(requestedSessionId) ?? null : null; + if (requestedSessionId && !sessionState) { + reply.code(404).send({ message: "unknown MCP session" }); + return; + } + + if (!sessionState && request.method === "GET") { + reply.code(405).send({ message: "GET SSE is not enabled for this MCP endpoint" }); + return; + } + + if (!sessionState) { + if (request.method === "DELETE") { + reply.code(404).send({ message: "unknown MCP session" }); + return; + } + if (!isInitializeRequest(request.body)) { + reply.code(400).send({ message: "MCP session must start with initialize" }); + return; + } + + const scope = scopedMcpAgent(request, reply, auth); + if (!scope) { + return; + } + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + transport.onclose = () => { + if (transport.sessionId) { + mcpSessions.delete(transport.sessionId); + } + }; + transport.onerror = (error) => { + app.log.error({ error }, "mcp transport error"); + }; + + const server = buildAmeshMcpServer(scope, { + repository, + nodeSockets, + sendToNode + }); + await server.connect(transport); + sessionState = { + scope, + transport + }; + } + + reply.hijack(); + try { + await sessionState.transport.handleRequest(request.raw, reply.raw, request.body); + const createdSessionId = sessionState.transport.sessionId; + if (createdSessionId && !mcpSessions.has(createdSessionId)) { + mcpSessions.set(createdSessionId, sessionState); + } + } catch (error) { + if (sessionState.transport.sessionId) { + mcpSessions.delete(sessionState.transport.sessionId); + } + throw error; + } + } + app.get("/api/auth/session", async (request: FastifyRequest) => ({ authenticated: isAuthenticated(request.cookies[authConfig.cookieName]), username: authConfig.username @@ -701,6 +875,12 @@ function registerApiRoutes({ return { authenticated: false }; }); + app.route({ + method: ["GET", "POST", "DELETE"], + url: "/mcp", + handler: handleMcpRequest + }); + app.get("/api/nodes", { preHandler: requireBrowserAuth }, async () => (await topologySnapshot()).nodes); app.get("/api/agents", { preHandler: requireBrowserAuth }, async () => repository.listTopology().agents); app.get("/api/bootstrap", { preHandler: requireBrowserAuth }, async () => ({ registrationToken })); diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts new file mode 100644 index 0000000..6cf3e42 --- /dev/null +++ b/apps/server/src/mcp.ts @@ -0,0 +1,496 @@ +import { McpServer } from "@modelcontextprotocol/server"; +import { nanoid } from "nanoid"; +import * as z from "zod"; + +import type { ProtocolEnvelope, SessionRecord } from "@amesh/protocol"; + +import type { Repository } from "./repository.js"; + +type NodeSocket = { + nodeId: string; + send: (message: ProtocolEnvelope) => void; +}; + +export type McpScope = { + authMode: "admin_password" | "browser_session" | "registration_token"; + scopedAgentId: string | null; + scopedNodeId: string | null; +}; + +type BuildAmeshMcpServerDeps = { + repository: Repository; + nodeSockets: Map; + sendToNode: (nodeId: string, envelope: ProtocolEnvelope) => void; +}; + +const sessionStatusSchema = z.enum([ + "pending", + "running", + "completed", + "failed", + "cancelled" +]); + +const sessionFiltersSchema = z.object({ + agentId: z.string().trim().min(1).optional(), + sourceAgentId: z.string().trim().min(1).optional(), + parentSessionId: z.string().trim().min(1).optional(), + status: sessionStatusSchema.optional(), + limit: z.number().int().min(1).max(100).default(25) +}); + +const listAgentsSchema = z.object({ + nodeId: z.string().trim().min(1).optional(), + status: z.enum(["online", "offline", "error"]).optional() +}); + +const listConnectedAgentsSchema = z.object({ + sourceAgentId: z.string().trim().min(1).optional() +}); + +const startSessionSchema = z.object({ + targetAgentId: z.string().trim().min(1), + prompt: z.string().trim().min(1), + cwd: z.string().trim().min(1).optional(), + sourceAgentId: z.string().trim().min(1).optional(), + parentSessionId: z.string().trim().min(1).optional() +}); + +const sessionIdSchema = z.object({ + sessionId: z.string().trim().min(1) +}); + +function stableJson(value: unknown) { + return JSON.stringify(value, null, 2); +} + +function toolResult(data: Record) { + return { + content: [ + { + type: "text" as const, + text: stableJson(data) + } + ], + structuredContent: data + }; +} + +function resolveSourceAgentId(scope: McpScope, requestedSourceAgentId?: string) { + if (scope.scopedAgentId) { + if (requestedSourceAgentId && requestedSourceAgentId !== scope.scopedAgentId) { + throw new Error(`caller is scoped to ${scope.scopedAgentId}`); + } + return scope.scopedAgentId; + } + return requestedSourceAgentId ?? null; +} + +function visibleSession(scope: McpScope, session: SessionRecord) { + if (!scope.scopedAgentId) { + return true; + } + return session.entryAgentId === scope.scopedAgentId || session.sourceAgentId === scope.scopedAgentId; +} + +function filteredAgents(scope: McpScope, repository: Repository) { + const topology = repository.listTopology(); + if (!scope.scopedAgentId) { + return topology.agents; + } + + const connectedIds = new Set([scope.scopedAgentId]); + for (const rule of topology.triggerRules) { + if (rule.mode === "allow" && rule.sourceAgentId === scope.scopedAgentId) { + connectedIds.add(rule.targetAgentId); + } + } + return topology.agents.filter((agent) => connectedIds.has(agent.id)); +} + +function resolveTargetCwd( + repository: Repository, + targetAgentId: string, + requestedCwd?: string +) { + const agent = repository.findAgent(targetAgentId); + if (!agent) { + throw new Error(`agent not found: ${targetAgentId}`); + } + + const fixedCwd = typeof agent.capabilities.cwd === "string" ? agent.capabilities.cwd : null; + const cwd = requestedCwd ?? fixedCwd ?? null; + const node = repository.findNode(agent.nodeId); + if (!node) { + throw new Error(`node not found for agent: ${targetAgentId}`); + } + if (requestedCwd && requestedCwd !== fixedCwd && !node.paths.includes(requestedCwd)) { + throw new Error(`folder is not exposed on node: ${requestedCwd}`); + } + + return { agent, node, cwd }; +} + +function ensureTargetNodeOnline( + repository: Repository, + nodeSockets: Map, + targetAgentId: string +) { + const agent = repository.findAgent(targetAgentId); + if (!agent) { + throw new Error(`agent not found: ${targetAgentId}`); + } + const node = repository.findNode(agent.nodeId); + if (!node) { + throw new Error(`node not found for agent: ${targetAgentId}`); + } + if (node.status !== "online" || !nodeSockets.has(node.id)) { + throw new Error(`node is offline for agent: ${targetAgentId}`); + } + return agent; +} + +function startUserSession( + repository: Repository, + nodeSockets: Map, + sendToNode: (nodeId: string, envelope: ProtocolEnvelope) => void, + args: z.infer +) { + const { agent, cwd } = resolveTargetCwd(repository, args.targetAgentId, args.cwd); + ensureTargetNodeOnline(repository, nodeSockets, args.targetAgentId); + + const session = repository.createSession({ + entryAgentId: args.targetAgentId, + initiator: "user", + cwd + }); + repository.updateSessionStatus(session.id, "running"); + repository.appendSessionEvent({ + sessionId: session.id, + eventType: "session.created", + sourceAgentId: null, + targetAgentId: args.targetAgentId, + payload: { + prompt: args.prompt, + via: "mcp" + } + }); + + sendToNode(agent.nodeId, { + type: "session.start", + requestId: nanoid(10), + sessionId: session.id, + source: "server", + target: agent.nodeId, + payload: { + sessionId: session.id, + agentId: args.targetAgentId, + prompt: args.prompt, + initiator: "user", + cwd, + parentSessionId: null + } + }); + + const state = repository.getSession(session.id); + if (!state) { + throw new Error(`session disappeared after creation: ${session.id}`); + } + return state; +} + +function startAgentSession( + scope: McpScope, + repository: Repository, + nodeSockets: Map, + sendToNode: (nodeId: string, envelope: ProtocolEnvelope) => void, + args: z.infer +) { + const sourceAgentId = resolveSourceAgentId(scope, args.sourceAgentId); + if (!sourceAgentId) { + throw new Error("sourceAgentId is required for agent-initiated sessions"); + } + if (!repository.canInvoke(sourceAgentId, args.targetAgentId)) { + if (args.parentSessionId) { + repository.appendSessionEvent({ + sessionId: args.parentSessionId, + eventType: "session.invocation.denied", + sourceAgentId, + targetAgentId: args.targetAgentId, + payload: { + reason: "missing_allow_rule", + via: "mcp" + } + }); + } + throw new Error(`missing allow rule from ${sourceAgentId} to ${args.targetAgentId}`); + } + + const parent = args.parentSessionId ? repository.getSession(args.parentSessionId) : null; + if (args.parentSessionId && !parent) { + throw new Error(`parent session not found: ${args.parentSessionId}`); + } + if ( + parent && + parent.session.entryAgentId !== sourceAgentId && + parent.session.sourceAgentId !== sourceAgentId + ) { + throw new Error(`parent session ${args.parentSessionId} is not visible to ${sourceAgentId}`); + } + + const { agent, cwd } = resolveTargetCwd(repository, args.targetAgentId, args.cwd); + ensureTargetNodeOnline(repository, nodeSockets, args.targetAgentId); + + if (args.parentSessionId) { + repository.appendSessionEvent({ + sessionId: args.parentSessionId, + eventType: "session.invocation.requested", + sourceAgentId, + targetAgentId: args.targetAgentId, + payload: { + parentSessionId: args.parentSessionId, + sourceAgentId, + targetAgentId: args.targetAgentId, + prompt: args.prompt, + via: "mcp" + } + }); + } + + const childSession = repository.createLinkedSession({ + entryAgentId: args.targetAgentId, + initiator: "agent", + cwd, + parentSessionId: args.parentSessionId ?? null, + sourceAgentId + }); + repository.updateSessionStatus(childSession.id, "running"); + + if (args.parentSessionId) { + repository.appendSessionEvent({ + sessionId: args.parentSessionId, + eventType: "session.invocation.allowed", + sourceAgentId, + targetAgentId: args.targetAgentId, + payload: { + childSessionId: childSession.id, + via: "mcp" + } + }); + } + + sendToNode(agent.nodeId, { + type: "session.start", + requestId: nanoid(10), + sessionId: childSession.id, + source: "server", + target: agent.nodeId, + payload: { + sessionId: childSession.id, + agentId: args.targetAgentId, + prompt: args.prompt, + initiator: "agent", + cwd, + parentSessionId: args.parentSessionId ?? null + } + }); + + const state = repository.getSession(childSession.id); + if (!state) { + throw new Error(`session disappeared after creation: ${childSession.id}`); + } + return state; +} + +export function buildAmeshMcpServer(scope: McpScope, deps: BuildAmeshMcpServerDeps) { + const server = new McpServer({ + name: "amesh-control", + version: "0.1.0" + }); + + server.registerTool( + "get_scope", + { + description: "Return the MCP caller scope Amesh resolved for this session." + }, + async () => + toolResult({ + authMode: scope.authMode, + scopedAgentId: scope.scopedAgentId, + scopedNodeId: scope.scopedNodeId + }) + ); + + server.registerTool( + "list_agents", + { + description: + "List Amesh agents visible to this caller. Scoped callers see themselves and their allowed downstream agents.", + inputSchema: listAgentsSchema + }, + async (rawArgs: unknown) => { + const args = listAgentsSchema.parse(rawArgs); + const agents = filteredAgents(scope, deps.repository).filter((agent) => { + if (args.nodeId && agent.nodeId !== args.nodeId) { + return false; + } + if (args.status && agent.status !== args.status) { + return false; + } + return true; + }); + return toolResult({ agents }); + } + ); + + server.registerTool( + "list_connected_agents", + { + description: + "List agents the source agent is explicitly allowed to invoke through Amesh trigger rules.", + inputSchema: listConnectedAgentsSchema + }, + async (rawArgs: unknown) => { + const args = listConnectedAgentsSchema.parse(rawArgs); + const sourceAgentId = resolveSourceAgentId(scope, args.sourceAgentId); + if (!sourceAgentId) { + throw new Error("sourceAgentId is required when the MCP session is not scoped to an agent"); + } + + const topology = deps.repository.listTopology(); + const targetIds = new Set( + topology.triggerRules + .filter((rule) => rule.mode === "allow" && rule.sourceAgentId === sourceAgentId) + .map((rule) => rule.targetAgentId) + ); + const agents = topology.agents.filter((agent) => targetIds.has(agent.id)); + return toolResult({ sourceAgentId, agents }); + } + ); + + server.registerTool( + "start_session", + { + description: + "Start a new Amesh session against a target agent. Scoped callers may start agent-initiated child sessions with optional parentSessionId lineage.", + inputSchema: startSessionSchema + }, + async (rawArgs: unknown) => { + const args = startSessionSchema.parse(rawArgs); + const state = + scope.scopedAgentId || args.sourceAgentId + ? startAgentSession(scope, deps.repository, deps.nodeSockets, deps.sendToNode, args) + : startUserSession(deps.repository, deps.nodeSockets, deps.sendToNode, args); + return toolResult(state as unknown as Record); + } + ); + + server.registerTool( + "list_sessions", + { + description: + "List recent Amesh sessions visible to this caller. Scoped callers only see sessions they entered or launched.", + inputSchema: sessionFiltersSchema + }, + async (rawArgs: unknown) => { + const args = sessionFiltersSchema.parse(rawArgs); + const sessions = deps.repository + .listSessions() + .filter((session) => visibleSession(scope, session)) + .filter((session) => { + if (args.agentId && session.entryAgentId !== args.agentId) { + return false; + } + if (args.sourceAgentId && session.sourceAgentId !== args.sourceAgentId) { + return false; + } + if (args.parentSessionId && session.parentSessionId !== args.parentSessionId) { + return false; + } + if (args.status && session.status !== args.status) { + return false; + } + return true; + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, args.limit); + return toolResult({ sessions }); + } + ); + + server.registerTool( + "get_session", + { + description: "Return one Amesh session with its event history.", + inputSchema: sessionIdSchema + }, + async (rawArgs: unknown) => { + const args = sessionIdSchema.parse(rawArgs); + const state = deps.repository.getSession(args.sessionId); + if (!state) { + throw new Error(`session not found: ${args.sessionId}`); + } + if (!visibleSession(scope, state.session)) { + throw new Error(`session is not visible to this caller: ${args.sessionId}`); + } + return toolResult(state as unknown as Record); + } + ); + + server.registerTool( + "cancel_session", + { + description: "Cancel a running Amesh session.", + inputSchema: sessionIdSchema + }, + async (rawArgs: unknown) => { + const args = sessionIdSchema.parse(rawArgs); + const state = deps.repository.getSession(args.sessionId); + if (!state) { + throw new Error(`session not found: ${args.sessionId}`); + } + if (!visibleSession(scope, state.session)) { + throw new Error(`session is not visible to this caller: ${args.sessionId}`); + } + + const agent = deps.repository.findAgent(state.session.entryAgentId); + if (!agent) { + throw new Error(`entry agent missing for session: ${args.sessionId}`); + } + ensureTargetNodeOnline(deps.repository, deps.nodeSockets, agent.id); + + deps.repository.updateSessionStatus(state.session.id, "cancelled"); + deps.repository.appendSessionEvent({ + sessionId: state.session.id, + eventType: "session.cancelled", + sourceAgentId: scope.scopedAgentId, + targetAgentId: agent.id, + payload: { + reason: "mcp_cancelled", + via: "mcp" + } + }); + + deps.sendToNode(agent.nodeId, { + type: "session.cancel", + requestId: nanoid(10), + sessionId: state.session.id, + source: "server", + target: agent.nodeId, + payload: { + sessionId: state.session.id, + agentId: agent.id, + reason: "mcp_cancelled" + } + }); + + const next = deps.repository.getSession(state.session.id); + if (!next) { + throw new Error(`session disappeared after cancellation: ${state.session.id}`); + } + return toolResult(next as unknown as Record); + } + ); + + return server; +} diff --git a/apps/server/test/app.test.ts b/apps/server/test/app.test.ts index d34ffb6..fc9d96d 100644 --- a/apps/server/test/app.test.ts +++ b/apps/server/test/app.test.ts @@ -157,6 +157,73 @@ describe("server app", () => { socket.close(); }); + it("serves MCP initialize and tools/list over HTTP", async () => { + const socket = new WebSocket(`ws://${address}/ws?role=node&nodeId=node-1`); + await waitForOpen(socket); + socket.send(JSON.stringify(registerNode("node-1", "node-a"))); + socket.send( + JSON.stringify( + syncCapabilities("node-1", [{ id: "agent-1", name: "Planner", acpxAgent: "planner" }]) + ) + ); + await readNodeMessage(socket); + await waitForIdle(); + + const initialized = await fetchMcp(address, { + jsonrpc: "2.0", + id: "init-1", + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { + name: "vitest", + version: "1.0.0" + } + } + }); + expect(initialized.status).toBe(200); + expect(initialized.body).toMatchObject({ + jsonrpc: "2.0", + id: "init-1", + result: { + protocolVersion: "2025-06-18" + } + }); + + expect(typeof initialized.sessionId).toBe("string"); + if (!initialized.sessionId) { + throw new Error("MCP session id missing"); + } + + const tools = await fetchMcp( + address, + { + jsonrpc: "2.0", + id: "tools-1", + method: "tools/list", + params: {} + }, + { + sessionId: initialized.sessionId + } + ); + expect(tools.status).toBe(200); + const toolNames = (tools.body.result.tools as Array<{ name: string }>).map((tool) => tool.name); + expect(toolNames).toEqual( + expect.arrayContaining([ + "get_scope", + "list_agents", + "list_connected_agents", + "start_session", + "list_sessions", + "get_session", + "cancel_session" + ]) + ); + socket.close(); + }); + it("resolves the default sqlite path independently of process cwd", async () => { const originalCwd = process.cwd(); const tempCwd = await mkdtemp(join(tmpdir(), "amesh-db-cwd-")); @@ -300,6 +367,134 @@ describe("server app", () => { await guardedApp.close(); }); + it("lets a scoped MCP caller list connected agents and start an agent session", async () => { + const sourceSocket = new WebSocket(`ws://${address}/ws?role=node&nodeId=node-a`); + const targetSocket = new WebSocket(`ws://${address}/ws?role=node&nodeId=node-b`); + await waitForOpen(sourceSocket); + await waitForOpen(targetSocket); + + sourceSocket.send(JSON.stringify(registerNode("node-a", "source"))); + sourceSocket.send( + JSON.stringify( + syncCapabilities("node-a", [{ id: "agent-source", name: "Source", acpxAgent: "planner" }]) + ) + ); + targetSocket.send(JSON.stringify(registerNode("node-b", "target"))); + targetSocket.send( + JSON.stringify( + syncCapabilities("node-b", [{ id: "agent-target", name: "Target", acpxAgent: "reviewer" }]) + ) + ); + await readNodeMessage(sourceSocket); + await readNodeMessage(targetSocket); + await waitForIdle(); + + const ruleResponse = await injectAuthed(app, authCookie, { + method: "POST", + url: "/api/trigger-rules", + payload: { + sourceAgentId: "agent-source", + targetAgentId: "agent-target", + mode: "allow" + } + }); + expect(ruleResponse.statusCode).toBe(200); + + const initialized = await fetchMcp( + address, + { + jsonrpc: "2.0", + id: "init-scoped", + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { + name: "scoped-agent", + version: "1.0.0" + } + } + }, + { + agentId: "agent-source" + } + ); + expect(initialized.status).toBe(200); + const sessionId = initialized.sessionId; + if (!sessionId) { + throw new Error("MCP session id missing"); + } + + const connected = await fetchMcp( + address, + { + jsonrpc: "2.0", + id: "connected-1", + method: "tools/call", + params: { + name: "list_connected_agents", + arguments: {} + } + }, + { + sessionId + } + ); + expect(connected.status).toBe(200); + const connectedPayload = connected.body.result.structuredContent as { + sourceAgentId: string; + agents: Array<{ id: string }>; + }; + expect(connectedPayload.sourceAgentId).toBe("agent-source"); + expect(connectedPayload.agents).toEqual([ + expect.objectContaining({ + id: "agent-target" + }) + ]); + + const startMessagePromise = readNodeMessage(targetSocket); + const startResponse = await fetchMcp( + address, + { + jsonrpc: "2.0", + id: "start-1", + method: "tools/call", + params: { + name: "start_session", + arguments: { + targetAgentId: "agent-target", + prompt: "Review this diff." + } + } + }, + { + sessionId + } + ); + expect(startResponse.status).toBe(200); + const started = startResponse.body.result.structuredContent as { + session: { entryAgentId: string; initiator: string; sourceAgentId: string | null }; + }; + expect(started.session).toMatchObject({ + entryAgentId: "agent-target", + initiator: "agent", + sourceAgentId: "agent-source" + }); + + const startMessage = await startMessagePromise; + expect(startMessage).toMatchObject({ + type: "session.start", + payload: expect.objectContaining({ + agentId: "agent-target", + prompt: "Review this diff.", + initiator: "agent" + }) + }); + + sourceSocket.close(); + targetSocket.close(); + }); + it("marks topology nodes as requiring update when their reported version trails the latest release", async () => { const socket = new WebSocket(`ws://${address}/ws?role=node&nodeId=node-1`); await waitForOpen(socket); @@ -1157,3 +1352,31 @@ async function injectAuthed( } }); } + +async function fetchMcp( + address: string, + body: Record, + options: { + sessionId?: string; + agentId?: string; + nodeId?: string; + } = {} +) { + const response = await fetch(`http://${address}/mcp`, { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + authorization: "Bearer secret-pass", + ...(options.sessionId ? { "mcp-session-id": options.sessionId } : {}), + ...(options.agentId ? { "x-amesh-agent-id": options.agentId } : {}), + ...(options.nodeId ? { "x-amesh-node-id": options.nodeId } : {}) + }, + body: JSON.stringify(body) + }); + return { + status: response.status, + sessionId: response.headers.get("mcp-session-id"), + body: (await response.json()) as Record + }; +} diff --git a/apps/web/src/components/McpPanel.tsx b/apps/web/src/components/McpPanel.tsx new file mode 100644 index 0000000..bd3691f --- /dev/null +++ b/apps/web/src/components/McpPanel.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import type { AgentRecord } from "@amesh/protocol"; +import { fetchBootstrapConfig } from "../api.js"; + +type Props = { + agent: AgentRecord; + onClose: () => void; +}; + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + async function copy() { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1400); + } catch { + setCopied(false); + } + } + + return ( + + ); +} + +function mcpConfig(endpoint: string, registrationToken: string, agentId: string) { + return JSON.stringify( + { + mcpServers: { + amesh: { + url: endpoint, + headers: { + Authorization: `Bearer ${registrationToken}`, + "x-amesh-agent-id": agentId + } + } + } + }, + null, + 2 + ); +} + +export function McpPanel({ agent, onClose }: Props) { + const dialogRef = useRef(null); + const [registrationToken, setRegistrationToken] = useState(""); + const endpoint = typeof window !== "undefined" ? `${window.location.origin}/mcp` : ""; + + useEffect(() => { + let active = true; + void fetchBootstrapConfig() + .then((config) => { + if (!active) return; + setRegistrationToken(config.registrationToken); + }) + .catch(() => { /* silent */ }); + return () => { active = false; }; + }, []); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + function handleOverlayMouseDown(e: React.MouseEvent) { + if (e.target === e.currentTarget) onClose(); + } + + const configJson = registrationToken ? mcpConfig(endpoint, registrationToken, agent.id) : ""; + + return createPortal( +
+
+
+
+

MCP Config

+
{agent.name}
+
+ +
+ +

+ Paste into your MCP client config. This token scopes the connection to{" "} + {agent.name} only. +

+ +
+ Agent ID + {agent.id} +
+ +
+ Config JSON + {registrationToken ? ( +
+ + {configJson} +
+ ) : ( + Loading… + )} +
+
+
, + document.body + ); +} diff --git a/apps/web/src/components/NarrowFallback.tsx b/apps/web/src/components/NarrowFallback.tsx index f9cb64f..a7b1b1c 100644 --- a/apps/web/src/components/NarrowFallback.tsx +++ b/apps/web/src/components/NarrowFallback.tsx @@ -1,15 +1,18 @@ import { useMemo, useState } from "react"; import type { TopologySnapshot } from "@amesh/protocol"; +import type { AgentRecord } from "@amesh/protocol"; import { ArrowRight } from "lucide-react"; import { createTriggerRule } from "../api.js"; import { relativeTime } from "../lib/time.js"; +import { McpPanel } from "./McpPanel.js"; import { NodeSettingsButton } from "./NodeSettingsButton.js"; type Props = { topology: TopologySnapshot }; export function NarrowFallback({ topology }: Props) { const [connectionSourceAgentId, setConnectionSourceAgentId] = useState(null); + const [mcpAgent, setMcpAgent] = useState(null); const agentsById = useMemo( () => new Map(topology.agents.map((agent) => [agent.id, agent])), [topology.agents] @@ -91,6 +94,16 @@ export function NarrowFallback({ topology }: Props) { > + {" "} + {typeof agent.capabilities.cwd === "string" ? ( <> {" "} @@ -142,6 +155,10 @@ export function NarrowFallback({ topology }: Props) { ); })} + + {mcpAgent ? ( + setMcpAgent(null)} /> + ) : null} ); } diff --git a/apps/web/src/components/NodeCard.tsx b/apps/web/src/components/NodeCard.tsx index f8953f5..9b0567d 100644 --- a/apps/web/src/components/NodeCard.tsx +++ b/apps/web/src/components/NodeCard.tsx @@ -1,9 +1,10 @@ import { Handle, Position } from "@xyflow/react"; import { useNavigate } from "@tanstack/react-router"; -import { ArrowRight } from "lucide-react"; +import { useState } from "react"; import type { AgentRecord, AgentStatus, NodeRecord, NodeStatus } from "@amesh/protocol"; import { relativeTime } from "../lib/time.js"; +import { McpPanel } from "./McpPanel.js"; import { NodeSettingsButton } from "./NodeSettingsButton.js"; export type NodeCardData = { @@ -24,6 +25,12 @@ function nodePill(status: NodeStatus) { return "pill pill-offline"; } +function agentPill(status: AgentStatus) { + if (status === "online") return "pill pill-online"; + if (status === "error") return "pill pill-error"; + return "pill pill-offline"; +} + function nodePillLabel(status: NodeStatus) { return status; } @@ -33,111 +40,63 @@ function agentStatusLabel(status: AgentStatus) { } export function NodeCard({ data }: NodeCardProps) { - const { - node, - agents, - connectionSourceAgentId, - connectionSourceAgentName, - onConnectionPick - } = data.data; + const { node, agents } = data.data; const isOffline = node.status === "offline"; const navigate = useNavigate(); + const [mcpAgent, setMcpAgent] = useState(null); return (
+ {/* Node header */}
-
+

{node.name}

{node.host}
{isOffline ? ( -
- Last seen {relativeTime(node.lastSeenAt)} -
+
Last seen {relativeTime(node.lastSeenAt)}
) : null} {node.status === "pending" ? (
Waiting for first heartbeat.
) : null}
-
+
{nodePillLabel(node.status)} -
event.stopPropagation()}> +
e.stopPropagation()}>
+ {/* Agent cards */} {agents.length === 0 ? (
No agents advertised yet.
) : (
    {agents.map((agent) => { const chatDisabled = isOffline || agent.status !== "online"; - const connectionSelected = connectionSourceAgentId === agent.id; - const connectionDisabled = isOffline || agent.status !== "online"; - const connectionLabel = connectionSourceAgentId - ? connectionSelected - ? `Cancel connection from ${agent.name}` - : `Connect ${connectionSourceAgentName ?? "selected agent"} to ${agent.name}` - : `Start connection from ${agent.name}`; + const mcpOpen = mcpAgent?.id === agent.id; + return ( -
  • -
    -
    {agent.name}
    -
    {agent.id}
    - {typeof agent.capabilities.cwd === "string" ? ( -
    {agent.capabilities.cwd}
    - ) : null} -
    -
    - - +
  • + {/* Connection handles at physical edges */} + + + + {/* Agent info */} +
    + {agent.name} {agent.status === "error" ? ( -
    event.stopPropagation()}> +
    e.stopPropagation()}> ( @@ -161,32 +116,67 @@ export function NodeCard({ data }: NodeCardProps) { />
    ) : ( - + {agentStatusLabel(agent.status)} )}
    - - +
    {agent.id}
    + + {typeof agent.capabilities.cwd === "string" ? ( +
    {agent.capabilities.cwd}
    + ) : null} + + {/* Agent actions */} +
    e.stopPropagation()} + > + + +
  • ); })}
)} + + {/* MCP config modal — portal to body */} + {mcpAgent ? ( + setMcpAgent(null)} /> + ) : null}
); } diff --git a/apps/web/src/components/TopBar.tsx b/apps/web/src/components/TopBar.tsx index 6cf0c62..7d75336 100644 --- a/apps/web/src/components/TopBar.tsx +++ b/apps/web/src/components/TopBar.tsx @@ -31,20 +31,20 @@ export function TopBar({ topology }: Props) { {topology.triggerRules.length === 1 ? "rule" : "rules"}
- +
+ +
{addOpen ? ( - setAddOpen(false)} - /> + setAddOpen(false)} /> ) : null} ); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2ffe8c5..056916e 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -263,6 +263,13 @@ button:focus { outline: none; } align-items: center; } +.topbar__actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: var(--s-2); +} + .wordmark { font: 600 0.875rem/1 var(--font-sans); letter-spacing: 0.18em; @@ -630,11 +637,10 @@ button:focus { outline: none; } /* Node card (custom React Flow node) */ .node-card { font: var(--t-body); - width: 240px; + width: 280px; background: var(--c-surface-raised); border: 1px solid var(--c-line); border-radius: var(--r-md); - overflow: hidden; transition: border-color var(--dur-fast) var(--ease-out), opacity var(--dur-base) var(--ease-out); } @@ -648,92 +654,148 @@ button:focus { outline: none; } } .node-card.selected { border-color: var(--c-accent); } +/* Node header */ .node-card__header { - padding: var(--s-3) var(--s-4) var(--s-2); + padding: var(--s-3) var(--s-3) var(--s-3) var(--s-4); display: grid; grid-template-columns: 1fr auto; gap: var(--s-2); align-items: start; border-bottom: 1px solid var(--c-line); } -.node-card__meta { +.node-card__identity { min-width: 0; } +.node-card__header-actions { display: grid; justify-items: end; gap: var(--s-2); + flex-shrink: 0; } .node-card__name { margin: 0; font: var(--t-title); color: var(--c-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .node-card__host { font: var(--t-mono); - font-size: 0.75rem; + font-size: 0.6875rem; color: var(--c-mute); margin-top: 2px; - word-break: break-all; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .node-card__lastseen { font: var(--t-label); letter-spacing: 0.06em; text-transform: uppercase; color: var(--c-mute); - margin-top: var(--s-2); + margin-top: var(--s-1); } +/* Agent list */ .node-card__agents { list-style: none; margin: 0; - padding: var(--s-1) 0; + padding: var(--s-2); + display: flex; + flex-direction: column; + gap: var(--s-2); } + +/* Each agent is its own inset card */ .node-card__agent { position: relative; - padding: var(--s-2) var(--s-4); - display: grid; - grid-template-columns: 1fr auto; - gap: var(--s-2); + padding: var(--s-2) var(--s-3); + background: var(--c-surface); + border: 1px solid var(--c-line); + border-radius: var(--r-sm); + transition: border-color var(--dur-fast) var(--ease-out); +} +.node-card__agent:hover { border-color: var(--c-line-strong); } + +/* Name + status pill on the same row */ +.node-card__agent-head { + display: flex; align-items: center; - border-top: 1px solid transparent; - transition: background var(--dur-fast) var(--ease-out); + justify-content: space-between; + gap: var(--s-2); + margin-bottom: 3px; } -.node-card__agent + .node-card__agent { border-top-color: var(--c-line); } -.node-card__agent:hover { background: var(--c-surface); } .node-card__agent-name { font: var(--t-title); + font-size: 0.8125rem; font-weight: 500; color: var(--c-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; } +.node-card__agent-status-btn { + border: 0; + padding: 0; + cursor: pointer; + background: transparent; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; +} +.node-card__agent-status-btn:hover { opacity: 0.75; } + .node-card__agent-id { font: var(--t-mono); - font-size: 0.6875rem; + font-size: 0.625rem; color: var(--c-mute); - margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .node-card__agent-cwd { font: var(--t-mono); - font-size: 0.6875rem; + font-size: 0.625rem; color: var(--c-accent-strong); - margin-top: 4px; - word-break: break-all; + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.node-card__agent-status { + +/* Action buttons row */ +.node-card__agent-actions { + display: flex; + align-items: center; + gap: var(--s-1); + margin-top: var(--s-2); + padding-top: var(--s-2); + border-top: 1px solid var(--c-line); +} +.node-card__action { font: var(--t-label); letter-spacing: 0.07em; text-transform: uppercase; -} -.node-card__agent-status--button { - border: 0; + padding: 3px var(--s-2); + border-radius: var(--r-xs); + border: 1px solid var(--c-line); background: transparent; - padding: 0; + color: var(--c-mute); cursor: pointer; + transition: color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out); } -.node-card__agent-status--button:hover { - text-decoration: underline; - text-underline-offset: 0.18em; +.node-card__action:hover:not([disabled]), +.node-card__action[aria-pressed="true"] { + color: var(--c-accent-strong); + background: var(--c-accent-soft); + border-color: var(--c-accent-line); +} +.node-card__action[disabled] { + opacity: 0.35; + cursor: not-allowed; } -.node-card__agent-status[data-status="online"] { color: var(--c-online); } -.node-card__agent-status[data-status="offline"] { color: var(--c-offline); } -.node-card__agent-status[data-status="error"] { color: var(--c-error); } .node-card__empty { padding: var(--s-3) var(--s-4); @@ -1118,25 +1180,26 @@ button:focus { outline: none; } } } -/* React Flow handles — agent source (right) + target (left) */ +/* React Flow handles — connection dots at agent card edges */ .react-flow__handle { - width: 9px; - height: 9px; + width: 10px; + height: 10px; border-radius: 50%; - background: var(--c-surface); - border: 1.5px solid var(--c-line-strong); + background: var(--c-surface-raised); + border: 2px solid var(--c-line-strong); transition: border-color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out); } .react-flow__handle-right { - right: -5px; + right: -6px; } .react-flow__handle-left { - left: -5px; + left: -6px; } .node-card__agent:hover .react-flow__handle { border-color: var(--c-accent); + background: var(--c-accent-soft); } .react-flow__handle.connectingfrom, .react-flow__handle.connectionindicator:hover { @@ -1419,6 +1482,96 @@ button:focus { outline: none; } animation: pulse 1.6s var(--ease-out) infinite; } +/* =============================================================== */ +/* MCP modal */ +/* =============================================================== */ + +.mcp-overlay { + position: fixed; + inset: 0; + background: oklch(22% 0.02 50 / 0.32); + z-index: 200; + display: grid; + place-items: center; + padding: var(--s-5); + animation: fade-in var(--dur-fast) var(--ease-out); +} +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.mcp-dialog { + width: min(580px, 100%); + background: var(--c-surface); + border: 1px solid var(--c-line); + border-radius: var(--r-md); + padding: var(--s-5); + box-shadow: var(--shadow-float); + display: grid; + gap: var(--s-4); + animation: dialog-in var(--dur-base) var(--ease-out-quint); +} +@keyframes dialog-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.mcp-dialog__header { + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + gap: var(--s-2); +} +.mcp-dialog__title { + margin: 0; + font: var(--t-headline); + color: var(--c-ink); +} +.mcp-dialog__subtitle { + font: var(--t-body); + color: var(--c-mute); + margin-top: 2px; +} +.mcp-dialog__desc { + margin: 0; + font: var(--t-body); + color: var(--c-mute); +} +.mcp-dialog__field { + display: grid; + gap: var(--s-1); +} +.mcp-dialog__label { + font: var(--t-label); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--c-mute); +} +.mcp-dialog__value { + display: block; + padding: 8px 10px; + border: 1px solid var(--c-line); + border-radius: var(--r-sm); + background: var(--c-surface-sunk); + color: var(--c-ink); + font: var(--t-mono); + font-size: 0.75rem; + overflow-x: auto; +} +.mcp-dialog__loading { + font: var(--t-body); + color: var(--c-mute); + font-style: italic; +} + +@media (max-width: 860px) { + .addnode-panel { + right: var(--s-4); + width: min(620px, calc(100vw - var(--s-5))); + } +} + /* =============================================================== */ /* Sessions */ /* =============================================================== */ @@ -2612,41 +2765,7 @@ button:focus { outline: none; } /* Mono utility used across transcript */ .font-mono { font-family: var(--font-mono); font-size: 0.75rem; letter-spacing: 0; } -/* Open chat button on agent rows */ -.node-card__agent-tail { - display: inline-flex; - align-items: center; - gap: var(--s-2); -} -.node-card__chat, -.node-card__connect { - border: 1px solid transparent; - background: transparent; - color: var(--c-mute); - padding: 4px; - border-radius: var(--r-xs); - cursor: pointer; - opacity: 0; - transition: opacity var(--dur-fast) var(--ease-out), - color var(--dur-fast) var(--ease-out), - background var(--dur-fast) var(--ease-out), - border-color var(--dur-fast) var(--ease-out); -} -.node-card__connect { - opacity: 1; -} -.node-card__chat:focus-visible, -.node-card__connect:focus-visible { opacity: 1; } -.node-card__chat:hover, -.node-card__connect:hover, -.node-card__connect[aria-pressed="true"] { - color: var(--c-accent-strong); - background: var(--c-accent-soft); - border-color: var(--c-accent-line); -} -.node-card__chat[disabled], -.node-card__connect[disabled] { cursor: not-allowed; } -.node-card__agent:hover .node-card__chat:not([disabled]) { opacity: 1; } +/* Agent action buttons — defined in node-card block above */ .narrow-card__error-link { border: 0; background: transparent; diff --git a/apps/web/test/app.test.tsx b/apps/web/test/app.test.tsx index b8873e6..a14c96a 100644 --- a/apps/web/test/app.test.tsx +++ b/apps/web/test/app.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../src/App.js"; @@ -321,6 +321,33 @@ describe("App shell", () => { ); }); + it("shows MCP config for an agent from the agent card", async () => { + narrowLayout = true; + topologyAgents = [ + { + id: "agent-1", + nodeId: "node-1", + name: "Planner", + backend: "acpx", + status: "online", + capabilities: { acpxAgent: "planner" } + } + ]; + window.history.pushState({}, "", "/"); + render(); + + await waitFor(() => + expect(screen.getByRole("button", { name: /mcp config for planner/i })).toBeTruthy() + ); + fireEvent.click(screen.getByRole("button", { name: /mcp config for planner/i })); + + const dialog = await screen.findByRole("dialog", { name: /mcp config for planner/i }); + const text = dialog.textContent ?? ""; + expect(text).toContain("agent-1"); + expect(text).toContain("server-registered-token"); + expect(text).toContain("/mcp"); + }); + it("loads the registration token into the empty-state install command", async () => { topologyNodes = []; window.history.pushState({}, "", "/"); diff --git a/docs/agent-control-mcp.md b/docs/agent-control-mcp.md new file mode 100644 index 0000000..9ac7ad9 --- /dev/null +++ b/docs/agent-control-mcp.md @@ -0,0 +1,9 @@ +# Agent control MCP + +- `amesh` now exposes a real HTTP MCP endpoint at `/mcp` on the control-plane server. +- The transport is MCP Streamable HTTP with stateful sessions, because `initialize` must survive across later `tools/list` and `tools/call` requests. +- The current server enables JSON response mode instead of SSE streaming. That keeps the first agent-control surface simple and works cleanly for request-response tools like session start, session lookup, and cancellation. +- MCP auth accepts either the existing admin browser session cookie or `Authorization: Bearer `. This keeps the first implementation aligned with the control plane's existing single-admin model instead of inventing a second credential system. +- A caller can scope the MCP session to an advertised mesh agent with `X-Amesh-Agent-Id` and optional `X-Amesh-Node-Id`. Scoped sessions only see that agent and its allowed downstream agents by default, and `start_session` becomes an agent-initiated launch instead of a user-initiated one. +- Scoped agent launches can include `parentSessionId`. When present, `amesh` records `session.invocation.requested` and `session.invocation.allowed` on the parent before starting the child session, so MCP-driven cross-agent work still shows up in normal session lineage. +- Per-agent secrets are intentionally not part of this first cut. The scope headers are trusted only after shared admin authentication. If we later need untrusted remote callers, the next step is dedicated issued credentials per node or per agent rather than widening the shared admin password further. diff --git a/docs/local-dev.md b/docs/local-dev.md index b5f208d..ba2b644 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -34,11 +34,14 @@ sh -n scripts/install-amesh-node.sh - The server can serve built dashboard assets directly from `apps/web/dist`, which is the deployment path used by the single-image Docker setup. - Browser access now uses an admin password plus an HTTP-only session cookie. Set `AUTH_ADMIN_PASSWORD` for a stable local password; if it is missing, the server generates a one-process UUID password and writes it to the server log at startup. - Set `AUTH_SESSION_SECRET` if you want browser sessions to survive a server restart. If it is missing, the server generates a random in-memory secret and all cookies are invalidated on restart. +- The control plane now also exposes `/mcp` as a real HTTP MCP endpoint. It accepts either the same admin browser session cookie or `Authorization: Bearer `. +- MCP callers can scope a session to a specific advertised agent with `X-Amesh-Agent-Id` and optional `X-Amesh-Node-Id`. In that mode, tool visibility defaults to that agent and its allowed downstream targets, and `start_session` launches agent-initiated child sessions rather than user-initiated chats. - The server enforces `AMESH_REGISTRATION_TOKEN` when set. In local development, leaving it unset keeps registration open; in deployed environments it should be set explicitly. - The Go daemon expects an `agents.json`-shaped capabilities file for local agent definitions, but `amesh-node detect --config ` can generate that file from live ACPX probing. - Node configs can also carry a top-level `paths` list. Those are exposed folders for sessions on that node; they are not separate agents. - Detection records locally installed ACPX-backed agent CLIs even if a given provider is slow to start or temporarily unhealthy; live topology health is still decided by the separate daemon-side ACPX health probe loop. - `corepack pnpm dev:daemon` installs a managed ACPX sidecar under `~/.local/share/amesh/acpx` if needed, writes `.amesh-agents.json` by detection on first run, saves `.amesh-node-state.json`, and then starts the long-lived daemon process against that generated config. +- If the local control-plane database was reset and the saved reconnect token is stale, `corepack pnpm dev:daemon` now deletes the stale `.amesh-node-state.json`, re-registers the local node automatically, and resumes. - `corepack pnpm dev:daemon` never uses `examples/agents.json`. If it finds stale local daemon state that still points at that example file, it deletes `.amesh-node-state.json` and the generated local config so the next start re-detects into `.amesh-agents.json`. - `corepack pnpm dev:daemon` also normalizes `~/.acpx/config.json` before detect/register. If `nonInteractivePermissions` is missing or invalid, it rewrites that field to `deny` so first local boot does not fail on stale ACPX user config. - The daemon now keeps running when the control plane goes away. It retries websocket connect, `node.resume`, and capability sync with backoff until the server returns. diff --git a/docs/past-failures.md b/docs/past-failures.md index 7a0629f..309e942 100644 --- a/docs/past-failures.md +++ b/docs/past-failures.md @@ -12,6 +12,12 @@ - Cause: detection treated executable presence as enough, while OpenClaw's ACPX target needs `openclaw acp` to run as a clean stdio ACP server from the daemon environment. - Mitigation: OpenClaw detection now probes every `openclaw` executable directory on `PATH` with ACPX `sessions ensure`, persists the first PATH ordering that initializes successfully, and omits OpenClaw if none can start ACP. A regression test covers a broken wrapper before a working executable. +## 2026-05-14: Local dev daemon could fail hard on a stale reconnect token + +- Symptom: `pnpm dev:daemon` connected to the local server, then exited immediately with `resume denied: invalid_reconnect_token`. +- Cause: the dev helper reused `.amesh-node-state.json` across runs, but a fresh local control-plane database no longer recognized the saved reconnect token. The helper treated that as fatal instead of re-registering the local node. +- Mitigation: `scripts/dev-daemon.sh` now detects that exact resume denial, deletes the stale local state file, re-runs `amesh-node register`, and restarts the daemon automatically. `scripts/test-dev-daemon.sh` covers the stale-token recovery path. + ## 2026-05-13: Installer service PATH could be truncated by spaces - Symptom: a node installed successfully, but the user service could later run with a truncated `PATH` when the shell PATH contained entries with spaces such as WSL-mounted `Program Files` directories. diff --git a/docs/testing.md b/docs/testing.md index dbf0d2f..90ad494 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,6 +10,7 @@ - `pnpm check:sentrux` - `go test ./...` - `bash -n scripts/dev-daemon.sh` +- `bash scripts/test-dev-daemon.sh` - `bash -n scripts/sentrux-check.sh` - `bash scripts/test-install-amesh-node.sh` - `sh -n install-amesh-node.sh` @@ -21,6 +22,7 @@ - The shared protocol package has schema tests for the envelope and session start payload. - The server owns integration tests for node registration, direct chat, continued chat via `session.input`, session cancel, and trigger allow or deny behavior over a real websocket port. +- The server also covers HTTP MCP initialize, tool discovery, scoped reachable-agent listing, and scoped agent-started sessions over the real `/mcp` endpoint. - The server also covers authenticated node update dispatch, including rejection for offline nodes. - The server also covers authenticated node detect dispatch, including rejection for offline nodes. - The server also covers zero-agent node registration so a node still appears in topology before any local agent inventory is available. @@ -33,4 +35,6 @@ - `scripts/test-install-amesh-node.sh` covers the installer's Node major parsing failure path and also executes the installer through stdin so the published `curl | bash` bootstrap shape stays working under `set -u`. - The GitHub Actions `CI` workflow also publishes dedicated `Knip` and `Sentrux` jobs so unused-code and architecture-rule regressions show up as separate status checks. - The web app owns UI coverage for topology rendering, session history recovery after refresh, and the dashboard `Detect agents`, `Update node`, and exposed-path actions. +- The web app also covers the top-bar MCP config panel so the copy-paste client snippets stay aligned with the server endpoint and scope headers. - The Go daemon owns table-driven tests for config loading, reconnect logic, update, detect, exposed-path command dispatch, and `acpx` process lifecycle including streamed output and cancellation. +- The dev helper script also has a regression shell test for the stale local reconnect-token path, so local `pnpm dev:daemon` re-registers automatically after a fresh control-plane reset. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e82803f..a3f70a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,18 @@ importers: '@amesh/protocol': specifier: workspace:* version: link:../../packages/protocol + '@cfworker/json-schema': + specifier: ^4.1.1 + version: 4.1.1 '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 + '@modelcontextprotocol/node': + specifier: 2.0.0-alpha.2 + version: 2.0.0-alpha.2(@modelcontextprotocol/server@2.0.0-alpha.2(@cfworker/json-schema@4.1.1))(hono@4.12.18) + '@modelcontextprotocol/server': + specifier: 2.0.0-alpha.2 + version: 2.0.0-alpha.2(@cfworker/json-schema@4.1.1) better-sqlite3: specifier: ^12.2.0 version: 12.9.0 @@ -35,6 +44,9 @@ importers: nanoid: specifier: ^5.1.6 version: 5.1.11 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@types/better-sqlite3': specifier: ^7.6.13 @@ -278,6 +290,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -507,6 +522,12 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -523,6 +544,22 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@modelcontextprotocol/node@2.0.0-alpha.2': + resolution: {integrity: sha512-7DQS8Nf3d/9Cv85hEtDjDU2kQg3UPuxcNvMDAvub91OJe4LmWkD5ZTTb/isrPzGBd2x8/I78dz8RPz/tkpqGDQ==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/server': ^2.0.0-alpha.2 + hono: ^4.11.4 + + '@modelcontextprotocol/server@2.0.0-alpha.2': + resolution: {integrity: sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA==} + engines: {node: '>=20'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -2249,6 +2286,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -3328,6 +3369,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@cfworker/json-schema@4.1.1': {} + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -3487,6 +3530,10 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@hono/node-server@1.19.14(hono@4.12.18)': + dependencies: + hono: 4.12.18 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3506,6 +3553,18 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/node@2.0.0-alpha.2(@modelcontextprotocol/server@2.0.0-alpha.2(@cfworker/json-schema@4.1.1))(hono@4.12.18)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.18) + '@modelcontextprotocol/server': 2.0.0-alpha.2(@cfworker/json-schema@4.1.1) + hono: 4.12.18 + + '@modelcontextprotocol/server@2.0.0-alpha.2(@cfworker/json-schema@4.1.1)': + dependencies: + zod: 4.4.3 + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -5083,6 +5142,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hono@4.12.18: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 diff --git a/scripts/dev-daemon.sh b/scripts/dev-daemon.sh index dc1f673..adc4411 100644 --- a/scripts/dev-daemon.sh +++ b/scripts/dev-daemon.sh @@ -90,6 +90,19 @@ if (needsWrite) { EOF } +register_node() { + AMESH_ACPX_PATH="$AMESH_ACPX_PATH" go run ./cmd/amesh-node register \ + --server "$SERVER_URL" \ + --token "$REGISTRATION_TOKEN" \ + --node-id "$NODE_ID" \ + --config "$CONFIG_PATH" \ + --state "$STATE_PATH" +} + +run_node() { + env AMESH_ACPX_PATH="$AMESH_ACPX_PATH" go run ./cmd/amesh-node run --state "$STATE_PATH" +} + if [[ -z "${REGISTRATION_TOKEN:-}" || "$REGISTRATION_TOKEN" == "demo-token" ]]; then if token="$(read_registration_token "$SERVER_ENV_PATH")"; then REGISTRATION_TOKEN="$token" @@ -127,12 +140,22 @@ if [[ ! -f "$CONFIG_PATH" ]]; then fi if [[ ! -f "$STATE_PATH" ]]; then - AMESH_ACPX_PATH="$AMESH_ACPX_PATH" go run ./cmd/amesh-node register \ - --server "$SERVER_URL" \ - --token "$REGISTRATION_TOKEN" \ - --node-id "$NODE_ID" \ - --config "$CONFIG_PATH" \ - --state "$STATE_PATH" + register_node +fi + +run_log="$(mktemp "${TMPDIR:-/tmp}/amesh-dev-daemon.XXXXXX.log")" +trap 'rm -f "$run_log"' EXIT + +if run_node 2>&1 | tee "$run_log"; then + exit 0 +fi +status=${PIPESTATUS[0]} + +if grep -q 'resume denied: invalid_reconnect_token' "$run_log"; then + echo "Detected stale local node state; re-registering $NODE_ID against $SERVER_URL" >&2 + rm -f "$STATE_PATH" + register_node + exec env AMESH_ACPX_PATH="$AMESH_ACPX_PATH" go run ./cmd/amesh-node run --state "$STATE_PATH" fi -exec env AMESH_ACPX_PATH="$AMESH_ACPX_PATH" go run ./cmd/amesh-node run --state "$STATE_PATH" +exit "$status" diff --git a/scripts/test-dev-daemon.sh b/scripts/test-dev-daemon.sh new file mode 100644 index 0000000..2147747 --- /dev/null +++ b/scripts/test-dev-daemon.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +mockbin="$tmpdir/mockbin" +mkdir -p "$mockbin" + +cat >"$mockbin/go" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +log_file="${TEST_LOG_FILE:?}" +state_path="${TEST_STATE_PATH:?}" +config_path="${TEST_CONFIG_PATH:?}" +counter_path="${TEST_RUN_COUNTER:?}" + +printf '%s\n' "$*" >>"$log_file" + +case "$*" in + *"./cmd/amesh-node detect"* ) + cat >"$config_path" <<'JSON' +{"nodeName":"lab","paths":[],"agents":[]} +JSON + ;; + *"./cmd/amesh-node register"* ) + cat >"$state_path" <"$counter_path" + if [[ "$count" -eq 1 ]]; then + echo "amesh-node 2026-05-14T19:43:54Z session connected node=node-a" >&2 + echo "2026/05/14 21:43:54 resume denied: invalid_reconnect_token" >&2 + exit 1 + fi + echo "amesh-node recovered" >&2 + ;; +esac +EOF +chmod +x "$mockbin/go" + +cat >"$tmpdir/state.json" <<'JSON' +{"nodeId":"node-a","reconnectToken":"stale-token","serverUrl":"ws://localhost:3001/ws?role=node","configPath":".amesh-agents.json"} +JSON + +touch "$tmpdir/acpx" +chmod +x "$tmpdir/acpx" + +TEST_LOG_FILE="$tmpdir/go.log" \ +TEST_STATE_PATH="$tmpdir/state.json" \ +TEST_CONFIG_PATH="$tmpdir/config.json" \ +TEST_RUN_COUNTER="$tmpdir/run-count" \ +PATH="$mockbin:$PATH" \ +CONFIG_PATH="$tmpdir/config.json" \ +STATE_PATH="$tmpdir/state.json" \ +AMESH_ACPX_PATH="$tmpdir/acpx" \ +REGISTRATION_TOKEN="demo-token" \ +NODE_ID="node-a" \ +SERVER_URL="ws://localhost:3001/ws?role=node" \ +bash "$repo_root/scripts/dev-daemon.sh" + +grep -q "./cmd/amesh-node register" "$tmpdir/go.log" +grep -q "./cmd/amesh-node run --state $tmpdir/state.json" "$tmpdir/go.log" +if [[ "$(cat "$tmpdir/run-count")" != "2" ]]; then + echo "expected dev daemon to retry run after stale reconnect token" >&2 + exit 1 +fi + +python_state="$(cat "$tmpdir/state.json")" +case "$python_state" in + *'"reconnectToken":"fresh-token"'* ) ;; + * ) + echo "expected re-register to replace stale reconnect token" >&2 + exit 1 + ;; +esac + +echo "dev-daemon stale reconnect recovery passed"