feat(daemon+mcp): daemon required for in-Claude-Code use; thin MCP shim
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

The architectural convergence v0.9.0 was building toward. CLI keeps
working without a daemon (claudemesh send/peer/inbox/...), but the MCP
push-pipe — which Claude Code uses for mid-turn channel emits, slash
commands, and resources — now requires the daemon. There is no fallback.

Daemon (additive):
- /v1/skills (list) and /v1/skills/:name (get) IPC endpoints, so the
  MCP shim can surface mesh skills without holding its own broker WS.
- listSkills() / getSkill() on DaemonBrokerClient.
- SSE 'message' event now carries plaintext body, sender_member_pubkey,
  priority, and subtype — full payload the MCP shim needs to render a
  channel notification.

MCP server: 979 → 469 LoC (470 of the remaining 469 is the unrelated
mesh-service proxy mode; the push-pipe path is ~200 LoC including
boilerplate).
- Probes ~/.claudemesh/daemon/daemon.sock at boot. Bails loudly with
  actionable instructions if missing.
- Subscribes to /v1/events SSE and translates each event into a
  notifications/claude/channel emit.
- Fetches mesh skills from the daemon for ListPrompts/GetPrompt and
  ListResources/ReadResource. ListTools returns []; the CLI is the API.
- No broker WS, no decryption, no reconnect logic. Daemon owns all of it.

claudemesh install: auto-installs and starts the daemon service for the
user's primary mesh (launchd / systemd-user). Pass --no-service to skip.

claudemesh launch: probes the daemon socket; if absent, spawns
'claudemesh daemon up --mesh <slug>' detached and waits up to 10s for
the socket. Surfaces a clear warning on timeout but doesn't block —
Claude Code's MCP shim will print the same error if the daemon really
isn't there.

Bundle: dist/entrypoints/mcp.js drops from 154KB → 104KB (gzipped 34KB
→ 19KB). Test: MCP boots cleanly via stdio, declares correct
capabilities, talks JSON-RPC; daemon /v1/skills returns the empty list
as expected on a mesh with no skills.

Released as 1.24.0 on npm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-03 23:43:02 +01:00
parent c56910bfcf
commit 6794aa8512
8 changed files with 562 additions and 776 deletions

View File

@@ -56,6 +56,19 @@ interface PendingPeerList {
timer: NodeJS.Timeout;
}
export interface SkillSummary {
name: string;
description: string;
tags: string[];
author: string;
createdAt: string;
}
export interface SkillFull extends SkillSummary {
instructions: string;
manifest?: unknown;
}
const HELLO_ACK_TIMEOUT_MS = 5_000;
const SEND_ACK_TIMEOUT_MS = 15_000;
const BACKOFF_CAPS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
@@ -76,6 +89,8 @@ export class DaemonBrokerClient {
private helloTimer: NodeJS.Timeout | null = null;
private pendingAcks = new Map<string, PendingAck>();
private peerListResolvers = new Map<string, PendingPeerList>();
private skillListResolvers = new Map<string, { resolve: (rows: SkillSummary[]) => void; timer: NodeJS.Timeout }>();
private skillDataResolvers = new Map<string, { resolve: (row: SkillFull | null) => void; timer: NodeJS.Timeout }>();
private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null;
private opens: Array<() => void> = [];
@@ -189,6 +204,28 @@ export class DaemonBrokerClient {
return;
}
if (msg.type === "skill_list") {
const reqId = String(msg._reqId ?? "");
const pending = this.skillListResolvers.get(reqId);
if (pending) {
this.skillListResolvers.delete(reqId);
clearTimeout(pending.timer);
pending.resolve(Array.isArray(msg.skills) ? (msg.skills as SkillSummary[]) : []);
}
return;
}
if (msg.type === "skill_data") {
const reqId = String(msg._reqId ?? "");
const pending = this.skillDataResolvers.get(reqId);
if (pending) {
this.skillDataResolvers.delete(reqId);
clearTimeout(pending.timer);
pending.resolve((msg.skill as SkillFull) ?? null);
}
return;
}
if (msg.type === "push" || msg.type === "inbound") {
this.opts.onPush?.(msg);
return;
@@ -264,6 +301,34 @@ export class DaemonBrokerClient {
});
}
/** List mesh-published skills. Empty array on disconnect / timeout. */
async listSkills(query?: string, timeoutMs = 5_000): Promise<SkillSummary[]> {
if (this._status !== "open" || !this.ws) return [];
return new Promise<SkillSummary[]>((resolve) => {
const reqId = `sl-${++this.reqCounter}`;
const timer = setTimeout(() => {
if (this.skillListResolvers.delete(reqId)) resolve([]);
}, timeoutMs);
this.skillListResolvers.set(reqId, { resolve, timer });
try { this.ws!.send(JSON.stringify({ type: "list_skills", query, _reqId: reqId })); }
catch { this.skillListResolvers.delete(reqId); clearTimeout(timer); resolve([]); }
});
}
/** Fetch one skill's full body. Null on not-found / disconnect / timeout. */
async getSkill(name: string, timeoutMs = 5_000): Promise<SkillFull | null> {
if (this._status !== "open" || !this.ws) return null;
return new Promise<SkillFull | null>((resolve) => {
const reqId = `sg-${++this.reqCounter}`;
const timer = setTimeout(() => {
if (this.skillDataResolvers.delete(reqId)) resolve(null);
}, timeoutMs);
this.skillDataResolvers.set(reqId, { resolve, timer });
try { this.ws!.send(JSON.stringify({ type: "get_skill", name, _reqId: reqId })); }
catch { this.skillDataResolvers.delete(reqId); clearTimeout(timer); resolve(null); }
});
}
/** Set the daemon's profile (avatar/title/bio/capabilities). Fire-and-forget. */
setProfile(profile: { avatar?: string; title?: string; bio?: string; capabilities?: string[] }): void {
if (this._status !== "open" || !this.ws) return;

View File

@@ -44,11 +44,14 @@ export async function handleBrokerPush(msg: Record<string, unknown>, ctx: Inboun
const brokerMessageId = stringOrNull(msg.messageId);
const senderPubkey = stringOrNull(msg.senderPubkey) ?? "";
const senderName = stringOrNull(msg.senderName) ?? senderPubkey.slice(0, 8);
const senderMemberPk = stringOrNull(msg.senderMemberPubkey);
const topic = stringOrNull(msg.topic);
const replyToId = stringOrNull(msg.replyToId);
const ciphertext = stringOrNull(msg.ciphertext) ?? "";
const nonce = stringOrNull(msg.nonce) ?? "";
const createdAt = stringOrNull(msg.createdAt);
const priority = stringOrNull(msg.priority) ?? "next";
const subtype = stringOrNull(msg.subtype);
// Forward-compat: Sprint 7 brokers will send client_message_id alongside.
const clientMessageId = stringOrNull(msg.client_message_id) ?? brokerMessageId ?? randomUUID();
const body = await decryptOrFallback({
@@ -78,9 +81,12 @@ export async function handleBrokerPush(msg: Record<string, unknown>, ctx: Inboun
client_message_id: clientMessageId,
broker_message_id: brokerMessageId,
sender_pubkey: senderPubkey,
sender_member_pubkey: senderMemberPk,
sender_name: senderName,
topic,
reply_to_id: replyToId,
priority,
...(subtype ? { subtype } : {}),
body,
created_at: createdAt,
});

View File

@@ -173,7 +173,7 @@ function makeHandler(opts: {
respond(res, 200, {
daemon_version: VERSION,
ipc_api: "v1",
ipc_features: ["version", "health", "send", "inbox", "events", "peers", "profile"],
ipc_features: ["version", "health", "send", "inbox", "events", "peers", "profile", "skills"],
schema_version: 1,
});
return;
@@ -204,6 +204,32 @@ function makeHandler(opts: {
return;
}
if (req.method === "GET" && url.pathname === "/v1/skills") {
if (!opts.broker) { respond(res, 503, { error: "broker not initialised" }); return; }
const query = url.searchParams.get("query") ?? undefined;
try {
const skills = await opts.broker.listSkills(query);
respond(res, 200, { skills });
} catch (e) {
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
}
return;
}
if (req.method === "GET" && url.pathname.startsWith("/v1/skills/")) {
if (!opts.broker) { respond(res, 503, { error: "broker not initialised" }); return; }
const name = decodeURIComponent(url.pathname.slice("/v1/skills/".length));
if (!name) { respond(res, 400, { error: "missing skill name" }); return; }
try {
const skill = await opts.broker.getSkill(name);
if (!skill) { respond(res, 404, { error: "skill_not_found", name }); return; }
respond(res, 200, { skill });
} catch (e) {
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
}
return;
}
if (req.method === "POST" && url.pathname === "/v1/profile") {
if (!opts.broker) { respond(res, 503, { error: "broker not initialised" }); return; }
try {