Files
claudemesh/apps/cli/src/mcp/server.ts
Alejandro Gutiérrez cef246a34a
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
chore(cli): typecheck clean (10 → 0)
- broker-actions: msg-status section header used out-of-scope `id`
  variable; was a real bug (renders "message undefined…" on the JSON
  path). Fixed to use the in-scope lookupId.
- exit-codes: add IO_ERROR (10) — referenced in three places by
  platform-actions but never declared.
- types/text-import.d.ts: declare wildcard `*.md` module so Bun's
  text-import attribute used by skill.ts typechecks.
- ipc/server: cast PeerSummary/SkillSummary through unknown before
  spreading into Record<string, unknown>.
- mcp/server: typed JSON.parse for SSE events.
- bridge/daemon-route: import path with .ts → .js (esm).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:23:55 +01:00

470 lines
18 KiB
TypeScript

/**
* MCP server (stdio transport) for claudemesh-cli.
*
* As of 1.24.0 / daemon v1.0, the MCP server is a thin daemon-SSE
* translator. It does NOT hold a broker WebSocket, decrypt messages, or
* track mesh state — those are the daemon's job. MCP just:
*
* 1. probes ~/.claudemesh/daemon/daemon.sock at boot;
* 2. fails loudly if the daemon isn't running (no fallback);
* 3. subscribes to /v1/events SSE and translates each event into a
* Claude Code `notifications/claude/channel` notification;
* 4. surfaces mesh-published skills as MCP prompts and resources by
* querying /v1/skills over IPC.
*
* The mesh-service proxy mode (claudemesh-cli --service <name>) lives at
* the bottom of this file and is unrelated — it acts as a sub-MCP-server
* for one deployed mesh-MCP service. Untouched by this rewrite.
*
* Spec: .artifacts/specs/2026-05-03-daemon-spec-v0.9.0.md plus the
* 1.24.0 daemon-required addendum.
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { existsSync } from "node:fs";
import { request as httpRequest, type IncomingMessage } from "node:http";
import { DAEMON_PATHS } from "~/daemon/paths.js";
import { VERSION } from "~/constants/urls.js";
import { readConfig } from "~/services/config/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
// ── daemon probe ───────────────────────────────────────────────────────
const DAEMON_BOOT_RETRIES = 4;
const DAEMON_BOOT_RETRY_MS = 500;
async function daemonReady(): Promise<boolean> {
for (let i = 0; i < DAEMON_BOOT_RETRIES; i++) {
if (existsSync(DAEMON_PATHS.SOCK_FILE)) return true;
await new Promise((r) => setTimeout(r, DAEMON_BOOT_RETRY_MS));
}
return false;
}
function bailNoDaemon(): never {
process.stderr.write(
"[claudemesh] daemon is not running.\n" +
" Start it: claudemesh daemon up --mesh <slug>\n" +
" Or install as service: claudemesh daemon install-service --mesh <slug>\n" +
" Diagnose: claudemesh doctor\n" +
"\n" +
" As of 1.24.0 the daemon is required for in-Claude-Code use of\n" +
" claudemesh. The CLI itself (claudemesh send/peer/inbox/...) still\n" +
" works without a daemon.\n",
);
process.exit(1);
}
// ── daemon IPC client (UDS) ────────────────────────────────────────────
interface DaemonGetResult { status: number; body: any }
function daemonGet(path: string): Promise<DaemonGetResult> {
return new Promise((resolve, reject) => {
const req = httpRequest(
{ socketPath: DAEMON_PATHS.SOCK_FILE, path, method: "GET", timeout: 5_000 },
(res: IncomingMessage) => {
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c as Buffer));
res.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
let body: any = null;
try { body = JSON.parse(text); } catch { body = text; }
resolve({ status: res.statusCode ?? 0, body });
});
},
);
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("daemon_ipc_timeout")));
req.end();
});
}
// ── daemon SSE subscription ────────────────────────────────────────────
interface DaemonEvent { kind: string; ts: string; data: Record<string, any> }
function subscribeEvents(onEvent: (e: DaemonEvent) => void): { close: () => void } {
let active = true;
let req: ReturnType<typeof httpRequest> | null = null;
const connect = (): void => {
if (!active) return;
req = httpRequest({
socketPath: DAEMON_PATHS.SOCK_FILE,
path: "/v1/events",
method: "GET",
headers: { Accept: "text/event-stream" },
});
let buffer = "";
req.on("response", (res: IncomingMessage) => {
res.setEncoding("utf8");
res.on("data", (chunk: string) => {
buffer += chunk;
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
if (!block.trim()) continue;
let kind = "message";
let dataLine = "";
for (const line of block.split("\n")) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) kind = line.slice(6).trim();
else if (line.startsWith("data:")) dataLine = line.slice(5).trim();
}
if (!dataLine) continue;
try {
const parsed = JSON.parse(dataLine) as Record<string, unknown>;
onEvent({ kind, ts: String(parsed.ts ?? ""), data: parsed });
} catch { /* malformed event; skip */ }
}
});
res.on("end", () => {
if (active) {
process.stderr.write("[claudemesh-mcp] sse stream ended; reconnecting in 1s\n");
setTimeout(connect, 1_000);
}
});
res.on("error", (err) => process.stderr.write(`[claudemesh-mcp] sse error: ${err.message}\n`));
});
req.on("error", (err) => {
process.stderr.write(`[claudemesh-mcp] sse connect error: ${err.message}\n`);
if (active) setTimeout(connect, 2_000);
});
req.end();
};
connect();
return {
close: () => { active = false; try { req?.destroy(); } catch { /* ignore */ } },
};
}
// ── main MCP server (push-pipe + skills) ──────────────────────────────
export async function startMcpServer(): Promise<void> {
// Mesh-service proxy mode: separate code path for proxying a deployed
// mesh MCP service into Claude Code. Unrelated to the daemon push-pipe.
const serviceIdx = process.argv.indexOf("--service");
if (serviceIdx !== -1 && process.argv[serviceIdx + 1]) {
return startServiceProxy(process.argv[serviceIdx + 1]!);
}
const ok = await daemonReady();
if (!ok) bailNoDaemon();
const server = new Server(
{ name: "claudemesh", version: VERSION },
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
);
// Tools: empty. The CLI is the API; the model invokes it via Bash.
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
// Prompts: mesh-published skills surfaced as `/skill-name` slash commands.
server.setRequestHandler(ListPromptsRequestSchema, async () => {
try {
const { status, body } = await daemonGet("/v1/skills");
if (status !== 200) return { prompts: [] };
const skills = (body?.skills as Array<{ name: string; description: string }> | undefined) ?? [];
return { prompts: skills.map((s) => ({ name: s.name, description: s.description, arguments: [] })) };
} catch { return { prompts: [] }; }
});
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
const name = req.params.name;
const { status, body } = await daemonGet(`/v1/skills/${encodeURIComponent(name)}`);
if (status === 404) throw new Error(`Skill "${name}" not found in the mesh`);
if (status !== 200) throw new Error(`daemon returned ${status} fetching skill`);
const skill = body.skill as { name: string; description: string; instructions: string; manifest?: any };
let content = skill.instructions;
const m = skill.manifest;
if (m && typeof m === "object") {
const fm: string[] = ["---"];
if (m.description) fm.push(`description: "${m.description}"`);
if (m.when_to_use) fm.push(`when_to_use: "${m.when_to_use}"`);
if (Array.isArray(m.allowed_tools) && m.allowed_tools.length) {
fm.push(`allowed-tools:\n${m.allowed_tools.map((t: string) => ` - ${t}`).join("\n")}`);
}
if (m.model) fm.push(`model: ${m.model}`);
if (m.context) fm.push(`context: ${m.context}`);
if (m.agent) fm.push(`agent: ${m.agent}`);
if (m.user_invocable === false) fm.push(`user-invocable: false`);
if (m.argument_hint) fm.push(`argument-hint: "${m.argument_hint}"`);
fm.push("---\n");
if (fm.length > 3) content = fm.join("\n") + content;
if (m.context === "fork") {
const agentType = m.agent || "general-purpose";
const modelHint = m.model ? `, model: "${m.model}"` : "";
const toolsHint = m.allowed_tools?.length
? `\nOnly use these tools: ${m.allowed_tools.join(", ")}.`
: "";
content = `IMPORTANT: Execute this skill in an isolated sub-agent. Use the Agent tool with subagent_type="${agentType}"${modelHint}. Pass the full instructions below as the agent prompt.${toolsHint}\n\n` + content;
}
}
return {
description: skill.description,
messages: [{ role: "user" as const, content: { type: "text" as const, text: content } }],
};
});
// Resources: mesh skills as `skill://claudemesh/<name>` URIs.
server.setRequestHandler(ListResourcesRequestSchema, async () => {
try {
const { body } = await daemonGet("/v1/skills");
const skills = (body?.skills as Array<{ name: string; description: string }> | undefined) ?? [];
return {
resources: skills.map((s) => ({
uri: `skill://claudemesh/${encodeURIComponent(s.name)}`,
name: s.name,
description: s.description,
mimeType: "text/markdown",
})),
};
} catch { return { resources: [] }; }
});
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
const uri = req.params.uri;
const m = uri.match(/^skill:\/\/claudemesh\/(.+)$/);
if (!m) throw new Error(`Unknown resource URI: ${uri}`);
const name = decodeURIComponent(m[1]!);
const { status, body } = await daemonGet(`/v1/skills/${encodeURIComponent(name)}`);
if (status === 404) throw new Error(`Skill "${name}" not found`);
if (status !== 200) throw new Error(`daemon returned ${status} fetching skill`);
const skill = body.skill as {
name: string; description: string; instructions: string;
tags?: string[]; manifest?: any;
};
const fm: string[] = ["---"];
fm.push(`name: ${skill.name}`);
fm.push(`description: "${skill.description}"`);
if (skill.tags?.length) fm.push(`tags: [${skill.tags.join(", ")}]`);
const mf = skill.manifest;
if (mf && typeof mf === "object") {
if (mf.when_to_use) fm.push(`when_to_use: "${mf.when_to_use}"`);
if (Array.isArray(mf.allowed_tools) && mf.allowed_tools.length) {
fm.push(`allowed-tools:\n${mf.allowed_tools.map((t: string) => ` - ${t}`).join("\n")}`);
}
if (mf.model) fm.push(`model: ${mf.model}`);
if (mf.context) fm.push(`context: ${mf.context}`);
}
fm.push("---\n");
return { contents: [{ uri, mimeType: "text/markdown", text: fm.join("\n") + skill.instructions }] };
});
// Subscribe to daemon events; translate to channel notifications.
const sub = subscribeEvents(async (ev) => {
if (ev.kind === "message") {
const d = ev.data;
const fromName = String(d.sender_name ?? "unknown");
const fromMember = String(d.sender_member_pubkey ?? d.sender_pubkey ?? "");
const body = String(d.body ?? "(decrypt failed)");
const priority = String(d.priority ?? "next");
const prioBadge = priority === "now" ? "[URGENT] " : priority === "low" ? "[low] " : "";
const topicTag = d.topic ? ` (#${d.topic})` : "";
const content = `${prioBadge}${fromName}${topicTag}: ${body}`;
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content,
meta: {
from_id: fromMember,
from_pubkey: fromMember,
from_session_pubkey: String(d.sender_pubkey ?? ""),
from_name: fromName,
mesh_slug: String(d.mesh ?? ""),
priority,
message_id: String(d.broker_message_id ?? d.id ?? ""),
client_message_id: String(d.client_message_id ?? ""),
...(d.topic ? { topic: String(d.topic) } : {}),
...(d.reply_to_id ? { reply_to_id: String(d.reply_to_id) } : {}),
...(d.subtype ? { subtype: String(d.subtype) } : {}),
},
},
});
} catch (err) {
process.stderr.write(`[claudemesh-mcp] channel emit failed: ${err}\n`);
}
} else if (ev.kind === "peer_join" || ev.kind === "peer_leave" || ev.kind === "system") {
const d = ev.data;
const eventName = String(d.event ?? ev.kind);
let content: string;
if (ev.kind === "peer_join") {
content = `[system] Peer "${String(d.name ?? "unknown")}" joined the mesh`;
} else if (ev.kind === "peer_leave") {
content = `[system] Peer "${String(d.name ?? "unknown")}" left the mesh`;
} else {
content = `[system] ${eventName}: ${JSON.stringify(d).slice(0, 240)}`;
}
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content,
meta: {
kind: "system",
event: eventName,
mesh_slug: String(d.mesh ?? ""),
},
},
});
} catch { /* best effort */ }
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
// Keep event loop active so SSE callbacks flush stdout promptly.
const keepalive = setInterval(() => { /* tick */ }, 1_000);
void keepalive;
const shutdown = (): void => {
clearInterval(keepalive);
sub.close();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
// ── mesh-service proxy mode (unchanged from prior versions) ────────────
/**
* Mesh service proxy — a thin MCP server that proxies ONE deployed service.
*
* Spawned by Claude Code as a native MCP entry. Connects to the broker,
* fetches tool schemas for the named service, and routes tool calls.
*
* If the broker WS drops, the proxy waits for reconnection (up to 10s)
* before failing tool calls. If the proxy process itself crashes, Claude
* Code will not auto-restart it.
*/
async function startServiceProxy(serviceName: string): Promise<void> {
const config = readConfig();
if (config.meshes.length === 0) {
process.stderr.write(`[mesh:${serviceName}] no meshes joined\n`);
process.exit(1);
}
const mesh = config.meshes[0]!;
const client = new BrokerClient(mesh, {
displayName: config.displayName ?? `proxy:${serviceName}`,
});
try {
await client.connect();
} catch (e) {
process.stderr.write(
`[mesh:${serviceName}] broker connect failed: ${e instanceof Error ? e.message : String(e)}\n`,
);
process.exit(1);
}
// Wait for hello_ack and service catalog.
await new Promise((r) => setTimeout(r, 1500));
let tools: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }> = [];
try {
const fetched = await client.getServiceTools(serviceName);
tools = fetched as typeof tools;
} catch {
const cached = client.serviceCatalog.find((s) => s.name === serviceName);
if (cached) tools = cached.tools as typeof tools;
}
if (tools.length === 0) {
process.stderr.write(`[mesh:${serviceName}] no tools found — service may not be running\n`);
}
const server = new Server(
{ name: `mesh:${serviceName}`, version: "0.1.0" },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: tools.map((t) => ({
name: t.name,
description: `[mesh:${serviceName}] ${t.description}`,
inputSchema: t.inputSchema as any,
})),
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const toolName = req.params.name;
const args = req.params.arguments ?? {};
if ((client.status as string) !== "open") {
let waited = 0;
while ((client.status as string) !== "open" && waited < 10_000) {
await new Promise((r) => setTimeout(r, 500));
waited += 500;
}
if ((client.status as string) !== "open") {
return {
content: [{ type: "text" as const, text: "Service temporarily unavailable — broker reconnecting. Retry in a few seconds." }],
isError: true,
};
}
}
try {
const result = await client.mcpCall(serviceName, toolName, args as Record<string, unknown>);
if (result.error) {
return { content: [{ type: "text" as const, text: `Error: ${result.error}` }], isError: true };
}
const resultText = typeof result.result === "string"
? result.result
: JSON.stringify(result.result, null, 2);
return { content: [{ type: "text" as const, text: resultText }] };
} catch (e) {
return {
content: [{ type: "text" as const, text: `Call failed: ${e instanceof Error ? e.message : String(e)}` }],
isError: true,
};
}
});
client.onPush((push) => {
if (push.event === "mcp_undeployed" && (push.eventData as any)?.name === serviceName) {
process.stderr.write(`[mesh:${serviceName}] service undeployed — exiting\n`);
client.close();
process.exit(0);
}
if (push.event === "mcp_updated" && (push.eventData as any)?.name === serviceName) {
const newTools = (push.eventData as any)?.tools;
if (Array.isArray(newTools)) {
tools = newTools as typeof tools;
server.notification({ method: "notifications/tools/list_changed" }).catch(() => { /* ignore */ });
}
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
const keepalive = setInterval(() => { /* tick */ }, 1_000);
void keepalive;
const shutdown = (): void => {
clearInterval(keepalive);
client.close();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}