Files
claudemesh/apps/cli/src/mcp/server.ts
Alejandro Gutiérrez 6780899185
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
feat(cli): 1.34.7 → 1.34.13 — multi-session correctness train
Seven-ship sequence that took the daemon from "works for one session"
to "internally consistent for N sessions on one daemon." Architecture
invariant after 1.34.13: every shared store / channel scopes by
recipient (SSE demux at bind layer + token forwarding, inbox per-
recipient columns, outbox sender-session routing).

- 1.34.7  inbox flush + delete commands
- 1.34.8  seen_at column + TTL prune + first echo guard
- 1.34.9  broader echo guard + system-event polish + staleness warning
- 1.34.10 per-session SSE demux (SseFilterOptions) + universal daemon
          (--mesh / --name deprecated) + daemon_started version stamp
- 1.34.11 inbox per-recipient column (storage half of 1.34.10)
- 1.34.12 daemon up detaches by default (logs to ~/.claudemesh/daemon/
          daemon.log; service units explicitly pass --foreground)
- 1.34.13 MCP forwards session token on /v1/events — the actual fix
          that activates 1.34.10's demux. Without this header the
          daemon's session resolved null, filter was empty, every MCP
          received the unfiltered global stream.

Roadmap entry at docs/roadmap.md captures the timeline + the four
known gaps tracked for follow-ups (launch env-var leak, broker
listPeers mesh-filter, kick on control-plane no-op, session caps as
first-class concept).

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

817 lines
36 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, appendFileSync } from "node:fs";
import { request as httpRequest, type IncomingMessage } from "node:http";
import { join } from "node:path";
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, opts: { sessionToken?: string | null } = {}): Promise<DaemonGetResult> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {};
// 1.34.2+: when the launched process gave us a session token, forward
// it on every IPC. Routes like `/v1/sessions/me` 401 without it, and
// routes like `/v1/peers` use it for default-mesh scoping.
if (opts.sessionToken) headers.Authorization = `ClaudeMesh-Session ${opts.sessionToken}`;
const req = httpRequest(
{ socketPath: DAEMON_PATHS.SOCK_FILE, path, method: "GET", timeout: 5_000, headers },
(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();
});
}
/** 1.34.8: best-effort POST /v1/inbox/seen so the MCP can stamp rows it
* just surfaced via a `<channel>` reminder. Failures are swallowed —
* read-state is a UX optimization, not a correctness gate. */
function daemonMarkSeen(ids: string[], sessionToken?: string | null): Promise<void> {
return new Promise((resolve) => {
if (ids.length === 0) { resolve(); return; }
const body = JSON.stringify({ ids });
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
};
if (sessionToken) headers.Authorization = `ClaudeMesh-Session ${sessionToken}`;
const req = httpRequest(
{ socketPath: DAEMON_PATHS.SOCK_FILE, path: "/v1/inbox/seen", method: "POST", timeout: 3_000, headers },
(res: IncomingMessage) => { res.on("data", () => { /* drain */ }); res.on("end", () => resolve()); },
);
req.on("error", () => resolve());
req.on("timeout", () => { req.destroy(); resolve(); });
req.write(body);
req.end();
});
}
// ── daemon SSE subscription ────────────────────────────────────────────
interface DaemonEvent { kind: string; ts: string; data: Record<string, any> }
function subscribeEvents(onEvent: (e: DaemonEvent) => void, opts: { sessionToken?: string | null } = {}): { close: () => void } {
let active = true;
let req: ReturnType<typeof httpRequest> | null = null;
const connect = (): void => {
if (!active) return;
// 1.34.13: forward the session token on the SSE subscription so the
// daemon's `/v1/events` route can scope the stream to this session
// via the SseFilterOptions demux added in 1.34.10. Without this
// header, `session` resolves to null in the IPC handler, the filter
// is empty, and every MCP receives every event — manifests as
// session A rendering DMs that arrived on B's session-WS. The
// launch helper sets CLAUDEMESH_IPC_TOKEN_FILE in the child env;
// readSessionTokenFromEnv() picks it up at MCP boot time.
const headers: Record<string, string> = { Accept: "text/event-stream" };
if (opts.sessionToken) headers.Authorization = `ClaudeMesh-Session ${opts.sessionToken}`;
req = httpRequest({
socketPath: DAEMON_PATHS.SOCK_FILE,
path: "/v1/events",
method: "GET",
headers,
});
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: {},
// 1.34.1 — declare the experimental `claude/channel` capability.
// Claude Code v2.1.x gates `notifications/claude/channel` on this
// exact key: its `xJ_(serverName, capabilities, pluginSource)` check
// returns {action:"skip", kind:"capability"} when
// `capabilities.experimental?.["claude/channel"]` is missing, and
// the notification handler is never registered → every channel
// emit lands on the floor, regardless of the
// `--dangerously-load-development-channels server:claudemesh` flag.
// This was the silent regression: pre-2.1.x clients didn't gate on
// this key, so the same MCP wire shape "worked" until Claude Code
// tightened the check. Verified by reading the binary at the
// offsets near `notifications/claude/channel` in the strings dump.
experimental: { "claude/channel": {} },
},
},
);
// 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 }] };
});
// 1.34.1: every channel emit (and SSE event arrival) writes to a
// per-pid log file under ~/.claudemesh/daemon/. Stderr from a Claude
// Code-spawned MCP server isn't surfaced anywhere visible to the
// user; without an on-disk trace we can't tell whether the SSE
// delivered the event, whether the bus reached the MCP, or whether
// server.notification rejected. The file path is stable across MCP
// restarts so users can `tail -f` to watch live.
const mcpLogPath = join(DAEMON_PATHS.DAEMON_DIR, `mcp-${process.pid}.log`);
const mcpLog = (msg: string, meta?: Record<string, unknown>): void => {
const line = JSON.stringify({ ts: new Date().toISOString(), pid: process.pid, msg, ...meta }) + "\n";
try { appendFileSync(mcpLogPath, line); } catch { /* logging must never crash */ }
};
mcpLog("mcp_started", { version: VERSION });
// 1.34.8: forward session token on /v1/inbox/seen so the daemon can
// resolve mesh scoping if it ever needs to. We read it once here and
// capture it in the closure since the MCP runs for the lifetime of
// the session; the env var doesn't rotate mid-process.
const { readSessionTokenFromEnv } = await import("~/services/session/token.js");
const sessionTokenForSeen = readSessionTokenFromEnv();
// Subscribe to daemon events; translate to channel notifications.
// 1.34.13: pass the session token so the daemon scopes the SSE
// stream via SseFilterOptions. Re-uses the same token already read
// for /v1/inbox/seen above.
const sub = subscribeEvents(async (ev) => {
mcpLog("sse_event_received", { kind: ev.kind });
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) } : {}),
},
},
});
mcpLog("channel_emitted", { content_preview: content.slice(0, 80), mesh: String(d.mesh ?? "") });
// 1.34.8: this row was just surfaced inline as a channel
// reminder; mark it seen so the next launch's welcome doesn't
// re-surface it as "unread." Best-effort: a failure here just
// means the welcome will list one extra row, not data loss.
const inboxRowId = String(d.id ?? "");
if (inboxRowId) {
void daemonMarkSeen([inboxRowId], sessionTokenForSeen).catch(() => { /* swallow */ });
}
} catch (err) {
mcpLog("channel_emit_failed", { err: String(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);
// 1.34.9: enrich peer_join/leave with the context the broker
// already ships (name, pubkey prefix, groups, returning summary).
// Pre-1.34.9 we surfaced just the displayName, which is ambiguous
// when two sessions share a name (e.g. two `agutierrez` peers in
// different cwds). Pubkey prefix disambiguates; groups hint at
// role (e.g. "[ops, devs]"). cwd / role aren't in the broker
// event yet, so they're skipped — adding them broker-side is a
// separate ship.
const renderPeerLine = (verb: string): string => {
const name = String(d.name ?? "unknown");
const pubkey = String(d.pubkey ?? "");
const pubkeyTag = pubkey ? ` (${pubkey.slice(0, 8)})` : "";
const groups = Array.isArray(d.groups) ? d.groups : [];
const groupNames = groups
.map((g) => (typeof g === "object" && g !== null && "name" in g ? String((g as { name: unknown }).name) : typeof g === "string" ? g : ""))
.filter(Boolean);
const groupsTag = groupNames.length > 0 ? ` [${groupNames.join(", ")}]` : "";
const lastSeen = typeof d.lastSeenAt === "string" ? d.lastSeenAt : null;
const summary = typeof d.summary === "string" && d.summary.trim() ? d.summary.trim() : null;
const returningTail = lastSeen
? ` — last seen ${new Date(lastSeen).toLocaleTimeString()}${summary ? ` · "${summary.slice(0, 80)}"` : ""}`
: "";
return `[system] Peer "${name}"${pubkeyTag}${groupsTag} ${verb} the mesh${returningTail}`;
};
let content: string;
if (ev.kind === "peer_join") {
content = renderPeerLine(eventName === "peer_returned" ? "returned to" : "joined");
} else if (ev.kind === "peer_leave") {
content = renderPeerLine("left");
} 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 ?? ""),
...(typeof d.name === "string" ? { peer_name: d.name } : {}),
...(typeof d.pubkey === "string" ? { peer_pubkey: d.pubkey } : {}),
...(Array.isArray(d.groups) ? { peer_groups: JSON.stringify(d.groups) } : {}),
...(typeof d.lastSeenAt === "string" ? { peer_last_seen_at: d.lastSeenAt } : {}),
...(typeof d.summary === "string" ? { peer_summary: d.summary } : {}),
},
},
});
} catch { /* best effort */ }
}
}, { sessionToken: sessionTokenForSeen });
// 1.34.6 — Welcome: single emit on oninitialized + 3s grace.
//
// The earlier "timing race" theory was wrong. Reading Claude Code's
// binary at the `notifications/claude/channel` Zod schema:
//
// IJ_ = y.object({
// method: y.literal("notifications/claude/channel"),
// params: y.object({
// content: y.string(),
// meta: y.record(y.string(), y.string()).optional()
// })
// })
//
// `meta` MUST be a record of string-to-string. Pre-1.34.6 the
// welcome shipped numbers (`peer_count`, `unread_count`) and arrays
// (`peer_names`, `latest_message_ids`) — Zod rejected the entire
// notification before it ever reached the channel handler.
//
// Live peer DMs always survived because their meta values all went
// through `String(...)`. The welcome was the only notification
// shape with non-string meta — uniquely affected, schema-rejected,
// silently dropped.
//
// 1.34.6 fixes the meta values (see `emitMeshWelcome`) so the
// notification passes validation; the dual-lane retry from 1.34.5
// is no longer necessary and would now surface a duplicate. Back to
// a single emit, with a 3s grace after `oninitialized` — enough for
// the React effect that registers the channel handler to run, but
// tight enough to feel like a launch handshake.
const WELCOME_GRACE_MS = 3_000;
let welcomeSent = false;
server.oninitialized = () => {
mcpLog("server_initialized");
if (welcomeSent) return;
welcomeSent = true;
setTimeout(() => { void emitMeshWelcome(server, mcpLog); }, WELCOME_GRACE_MS);
};
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-connected welcome. Runs once 5s after the MCP transport is up,
* regardless of inbox state. The point isn't just to summarize unread —
* an empty welcome still confirms to the user that the mesh pipe is
* live, names the session, says how many peers are visible, and lists
* the canonical CLI commands so the model can use them mid-turn.
*
* Composes from up to three best-effort daemon queries:
* - `/v1/sessions/me` → display name + session pubkey + mesh
* (requires session token; absent on bare `claudemesh mcp`)
* - `/v1/peers?mesh=…` → live peer count, filtered to non-control-plane
* - `/v1/inbox?…` → recent message count + up to 3 previews
*
* Each query degrades silently — a missing field becomes "unknown" or
* is omitted. The welcome ALWAYS emits unless the IPC socket is
* unreachable; that's the design contract: "you launched into the
* mesh, here's what you've got."
*/
async function emitMeshWelcome(
server: import("@modelcontextprotocol/sdk/server/index.js").Server,
mcpLog: (msg: string, meta?: Record<string, unknown>) => void,
): Promise<void> {
const { readSessionTokenFromEnv } = await import("~/services/session/token.js");
const sessionToken = readSessionTokenFromEnv();
// 1) Self identity. Token-less path (bare `claudemesh mcp` outside a
// launch) just leaves these undefined; the welcome still goes out.
let selfDisplayName: string | undefined;
let selfSessionPubkey: string | undefined;
let selfMeshSlug: string | undefined;
let selfRole: string | undefined;
if (sessionToken) {
try {
const { status, body } = await daemonGet("/v1/sessions/me", { sessionToken });
if (status === 200 && body?.session) {
selfDisplayName = body.session.displayName;
selfMeshSlug = body.session.mesh;
selfRole = body.session.role;
selfSessionPubkey = body.session.presence?.sessionPubkey;
}
} catch (e) { mcpLog("welcome_self_lookup_failed", { err: String(e) }); }
}
// 2) Live peer count. Match the same filter the launch banner uses
// (`channel !== "claudemesh-daemon"`) so the welcome's number agrees
// with the "N peers online" line that just printed in the terminal.
// We also fall back to `peerRole !== "control-plane"` for newer
// brokers that emit the role taxonomy. Excluding self uses both
// session pubkey AND session id (older brokers may not surface
// peerRole, so name-only matching would fail).
let peerCount = -1;
let peerNames: string[] = [];
try {
const path = selfMeshSlug ? `/v1/peers?mesh=${encodeURIComponent(selfMeshSlug)}` : "/v1/peers";
const { status, body } = await daemonGet(path, { sessionToken });
if (status === 200 && Array.isArray(body?.peers)) {
const peers = body.peers as Array<Record<string, unknown>>;
const real = peers.filter((p) => {
const channel = String(p.channel ?? "");
const peerRole = String(p.peerRole ?? "");
const isInfra = channel === "claudemesh-daemon" || peerRole === "control-plane";
if (isInfra) return false;
if (selfSessionPubkey && p.pubkey === selfSessionPubkey) return false;
return true;
});
peerCount = real.length;
peerNames = real
.map((p) => String(p.displayName ?? "unknown"))
.filter((n, i, arr) => arr.indexOf(n) === i)
.slice(0, 5);
mcpLog("welcome_peers_resolved", { total: peers.length, real: real.length });
} else {
mcpLog("welcome_peers_status", { status });
}
} catch (e) { mcpLog("welcome_peers_lookup_failed", { err: String(e) }); }
// 3) Unread inbox. 1.34.8 replaced the "last 24h" window with the
// proper read-state filter — `?unread_only=true` returns rows whose
// `seen_at` is NULL. The list call uses `mark_seen=false` so the
// welcome doesn't auto-stamp; we stamp explicitly via /v1/inbox/seen
// *after* we know the channel notification went out (otherwise a
// schema rejection would silently mark rows seen that the user
// never actually saw — the original 1.34.6 bug shape).
const inboxPath = selfMeshSlug
? `/v1/inbox?mesh=${encodeURIComponent(selfMeshSlug)}&unread_only=true&mark_seen=false&limit=50`
: `/v1/inbox?unread_only=true&mark_seen=false&limit=50`;
let inboxItems: Array<Record<string, unknown>> = [];
try {
const { status, body } = await daemonGet(inboxPath, { sessionToken });
if (status === 200 && Array.isArray(body?.items)) {
inboxItems = body.items as Array<Record<string, unknown>>;
}
} catch (e) { mcpLog("welcome_inbox_lookup_failed", { err: String(e) }); }
// Compose the body. Markdown-friendly so it renders cleanly in the
// Claude Code channel reminder block.
const lines: string[] = [];
const idTag = selfDisplayName
? `${selfDisplayName}${selfSessionPubkey ? ` (${selfSessionPubkey.slice(0, 8)})` : ""}${selfRole ? ` [${selfRole}]` : ""}`
: "session";
const meshTag = selfMeshSlug ? ` on mesh \`${selfMeshSlug}\`` : "";
lines.push(`🌐 [welcome] claudemesh connected — you are **${idTag}**${meshTag}.`);
if (peerCount === 0) {
lines.push(`👥 No other peers online right now.`);
} else if (peerCount > 0) {
const namesPreview = peerNames.join(", ");
const more = peerCount > peerNames.length ? ` …and ${peerCount - peerNames.length} more` : "";
lines.push(`👥 ${peerCount} peer${peerCount === 1 ? "" : "s"} online: ${namesPreview}${more}`);
} else {
lines.push(`👥 Peer list unavailable (daemon query failed).`);
}
if (inboxItems.length === 0) {
lines.push(`📥 No unread messages.`);
} else {
lines.push(`📥 ${inboxItems.length} unread message${inboxItems.length === 1 ? "" : "s"}:`);
for (const it of inboxItems.slice(0, 3)) {
const sender = String(it.sender_name ?? "unknown");
const senderPub = String(it.sender_pubkey ?? "").slice(0, 8);
const tag = sender !== senderPub ? `${sender} (${senderPub})` : senderPub;
const bodyText = (typeof it.body === "string" ? it.body : "(encrypted)").slice(0, 60);
const time = it.received_at ? new Date(String(it.received_at)).toLocaleTimeString() : "";
lines.push(` ${tag} ${time}: ${bodyText}`);
}
if (inboxItems.length > 3) lines.push(` …and ${inboxItems.length - 3} more`);
}
// CLI hints — what the model should call when the user asks. Listed
// here as a one-liner so the welcome stays compact.
lines.push(`💡 Use: \`claudemesh peer list\` · \`claudemesh send <peer> <msg>\` · \`claudemesh inbox\``);
// Skill pointer — the `claudemesh` skill in the user's Claude install
// documents every CLI verb, JSON shapes, channel attributes, and
// common patterns. If the model isn't already loaded with it, this is
// the cue to read it once before acting on the mesh.
lines.push(`📚 Read the \`claudemesh\` skill (SKILL.md) for full CLI / channel / inbox reference if not yet in context.`);
const content = lines.join("\n");
try {
// Claude Code's `notifications/claude/channel` schema is
// `meta: y.record(y.string(), y.string())` — string values only.
// Pre-1.34.6 we sent numbers / arrays in `peer_count`, `unread_count`,
// `peer_names`, `latest_message_ids`; Zod silently rejected the
// whole notification before it reached the channel handler. Live
// peer DMs survived because their meta values all went through
// `String(...)`. Coerce everything here too — arrays stringify as
// JSON so downstream consumers can re-parse if they want, and the
// counts become digit strings (parseable on the receiving side).
await server.notification({
method: "notifications/claude/channel",
params: {
content,
meta: {
kind: "welcome",
self_display_name: selfDisplayName ?? "",
self_session_pubkey: selfSessionPubkey ?? "",
self_role: selfRole ?? "",
mesh_slug: selfMeshSlug ?? "",
peer_count: peerCount >= 0 ? String(peerCount) : "",
peer_names: JSON.stringify(peerNames),
unread_count: String(inboxItems.length),
latest_message_ids: JSON.stringify(
inboxItems.slice(0, 10).map((it) => String(it.id ?? "")),
),
},
},
});
mcpLog("welcome_emitted", {
mesh: selfMeshSlug ?? "",
peer_count: peerCount,
unread_count: inboxItems.length,
});
// 1.34.8: stamp the rows we just surfaced. Done AFTER the
// notification succeeds so a Zod-rejected welcome (the 1.34.6 bug
// shape) doesn't silently mark rows seen that the user never
// actually saw. Best-effort.
if (inboxItems.length > 0) {
const ids = inboxItems.map((it) => String(it.id ?? "")).filter(Boolean);
if (ids.length > 0) {
void daemonMarkSeen(ids, sessionToken).catch(() => { /* swallow */ });
}
}
} catch (err) {
mcpLog("welcome_emit_failed", { err: String(err) });
}
}
// ── 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);
}