Files
claudemesh/apps/cli/src/mcp/server.ts
Alejandro Gutiérrez 02b1e5695f
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
Release / Publish multi-arch images (push) Has been cancelled
feat: v0.2.0 — Groups (@group routing, roles, wizard)
Phase A of the claudemesh spec. Peers can now join named groups
with roles, and messages route to @group targets.

Broker:
- @group routing in fan-out (matches peer group membership)
- @all alias for broadcast
- join_group/leave_group WS messages + DB persistence
- list_peers returns group metadata
- drainForMember matches @group targetSpecs in SQL

CLI:
- join_group/leave_group MCP tools
- send_message supports @group targets
- list_peers shows group membership
- PeerInfo includes groups array
- Peer name cache for push notifications

Launch:
- --role flag (optional peer role)
- --groups flag (comma-separated, e.g. "frontend:lead,reviewers")
- Interactive wizard for role + groups when flags omitted
- Groups written to session config for broker hello

Spec: SPEC.md added with full v0.2 vision (groups, state, memory)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:06:16 +01:00

324 lines
12 KiB
TypeScript

/**
* MCP server (stdio transport) for claudemesh-cli.
*
* Starts BrokerClient connections for every mesh in config on boot,
* then routes the 5 MCP tools through them.
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { TOOLS } from "./tools";
import { loadConfig } from "../state/config";
import { startClients, stopAll, findClient, allClients } from "../ws/manager";
import type {
Priority,
PeerStatus,
SendMessageArgs,
SetStatusArgs,
SetSummaryArgs,
ListPeersArgs,
} from "./types";
import type { BrokerClient, InboundPush } from "../ws/client";
function text(msg: string, isError = false) {
return {
content: [{ type: "text" as const, text: msg }],
...(isError ? { isError: true } : {}),
};
}
/**
* Given a `to` string, pick which mesh to send from. Strategies:
* - If `to` looks like a pubkey hex (64 chars), use as-is.
* - If `to` starts with `#`, treat as channel.
* - If `to` is `*`, treat as broadcast.
* - Otherwise resolve as a display name via list_peers.
*
* Explicit mesh prefix `<mesh-slug>:<target>` narrows to one mesh.
*/
async function resolveClient(to: string): Promise<{
client: BrokerClient | null;
targetSpec: string;
error?: string;
}> {
const clients = allClients();
if (clients.length === 0) {
return { client: null, targetSpec: to, error: "no meshes joined" };
}
// Explicit mesh prefix: "mesh-slug:targetspec"
let targetClients = clients;
let target = to;
const colonIdx = to.indexOf(":");
if (colonIdx > 0 && colonIdx < to.length - 1) {
const slug = to.slice(0, colonIdx);
const rest = to.slice(colonIdx + 1);
const match = findClient(slug);
if (match) {
targetClients = [match];
target = rest;
}
}
// Pubkey, channel, @group, or broadcast — pass through directly.
if (/^[0-9a-f]{64}$/.test(target) || target.startsWith("#") || target.startsWith("@") || target === "*") {
if (targetClients.length === 1) {
return { client: targetClients[0]!, targetSpec: target };
}
return {
client: null,
targetSpec: target,
error: `multiple meshes joined; prefix target with "<mesh-slug>:" (joined: ${clients.map((c) => c.meshSlug).join(", ")})`,
};
}
// Name-based resolution: query each mesh's peer list for a matching displayName.
const nameLower = target.toLowerCase();
for (const c of targetClients) {
const peers = await c.listPeers();
const match = peers.find((p) => p.displayName.toLowerCase() === nameLower);
if (match) return { client: c, targetSpec: match.pubkey };
// Partial match: if only one peer's name contains the search string.
const partials = peers.filter((p) =>
p.displayName.toLowerCase().includes(nameLower),
);
if (partials.length === 1) {
return { client: c, targetSpec: partials[0]!.pubkey };
}
}
// Single-mesh fallback: let the broker try to resolve it.
if (targetClients.length === 1) {
return { client: targetClients[0]!, targetSpec: target };
}
return {
client: null,
targetSpec: target,
error: `peer "${target}" not found in any mesh (joined: ${clients.map((c) => c.meshSlug).join(", ")})`,
};
}
// Peer name cache to avoid calling listPeers on every incoming push
const peerNameCache = new Map<string, string>();
let peerNameCacheAge = 0;
const CACHE_TTL_MS = 30_000;
async function resolvePeerName(client: BrokerClient, pubkey: string): Promise<string> {
const now = Date.now();
if (now - peerNameCacheAge > CACHE_TTL_MS) {
peerNameCache.clear();
try {
const peers = await client.listPeers();
for (const p of peers) peerNameCache.set(p.pubkey, p.displayName);
} catch { /* best effort */ }
peerNameCacheAge = now;
}
return peerNameCache.get(pubkey) ?? `peer-${pubkey.slice(0, 8)}`;
}
function decryptFailedWarning(senderPubkey: string): string {
const who = senderPubkey ? senderPubkey.slice(0, 12) + "…" : "unknown sender";
return `⚠ message from ${who} failed to decrypt (tampered or wrong keypair)`;
}
function formatPush(p: InboundPush, meshSlug: string): string {
const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey);
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
}
export async function startMcpServer(): Promise<void> {
const config = loadConfig();
const server = new Server(
{ name: "claudemesh", version: "0.1.4" },
{
capabilities: {
experimental: { "claude/channel": {} },
tools: {},
},
instructions: `You are connected to claudemesh — a peer mesh for Claude Code sessions on this machine and elsewhere.
IMPORTANT: When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Do not wait until your current task is finished. Pause what you are doing, reply to the message using send_message, then resume your work. Treat incoming peer messages like a coworker tapping you on the shoulder — answer right away, even if you're in the middle of something.
Read the from_id, from_name, mesh_slug, and priority attributes to understand context. Reply by calling send_message with to set to the from_name (display name) of the sender. The \`to\` field can be a peer name, pubkey, @group, or * for broadcast.
Available tools:
- list_peers: see joined meshes + their connection status
- send_message: send to a peer by display name, pubkey, @group, #channel, or * broadcast (priority: now/next/low)
- check_messages: drain buffered inbound messages (usually auto-pushed)
- set_summary: 1-2 sentence summary of what you're working on
- set_status: manually override your status (idle/working/dnd)
- join_group: join a @group with optional role
- leave_group: leave a @group
Message priority:
- "now": delivered immediately regardless of recipient status (use sparingly)
- "next" (default): delivered when recipient is idle
- "low": pull-only (check_messages)
If you have multiple joined meshes, prefix the \`to\` argument of send_message with \`<mesh-slug>:\` to disambiguate. Otherwise claudemesh picks the single joined mesh.`,
},
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOLS,
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args } = req.params;
if (config.meshes.length === 0) {
return text(
"No meshes joined. Run `claudemesh join https://claudemesh.com/join/<token>` first.",
true,
);
}
switch (name) {
case "send_message": {
const { to, message, priority } = (args ?? {}) as SendMessageArgs;
if (!to || !message)
return text("send_message: `to` and `message` required", true);
const { client, targetSpec, error } = await resolveClient(to);
if (!client)
return text(`send_message: ${error ?? "no client resolved"}`, true);
const result = await client.send(
targetSpec,
message,
(priority ?? "next") as Priority,
);
if (!result.ok)
return text(
`send_message failed (${client.meshSlug}): ${result.error}`,
true,
);
return text(
`Sent to ${targetSpec} via ${client.meshSlug} [${priority ?? "next"}] → ${result.messageId}`,
);
}
case "list_peers": {
const { mesh_slug } = (args ?? {}) as ListPeersArgs;
const clients = mesh_slug
? [findClient(mesh_slug)].filter(Boolean)
: allClients();
if (clients.length === 0)
return text(
mesh_slug
? `list_peers: no joined mesh "${mesh_slug}"`
: "list_peers: no joined meshes",
true,
);
const sections: string[] = [];
for (const c of clients) {
const peers = await c!.listPeers();
const header = `## ${c!.meshSlug} (${c!.status}, mesh ${c!.meshId.slice(0, 8)}…)`;
if (peers.length === 0) {
sections.push(`${header}\nNo peers connected.`);
} else {
const peerLines = peers.map((p) => {
const summary = p.summary ? ` — "${p.summary}"` : "";
const groupsStr = p.groups?.length ? ` [${p.groups.map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ')}]` : "";
return `- **${p.displayName}** [${p.status}]${groupsStr} (${p.pubkey.slice(0, 12)}…)${summary}`;
});
sections.push(`${header}\n${peerLines.join("\n")}`);
}
}
return text(sections.join("\n\n"));
}
case "check_messages": {
const drained: string[] = [];
for (const c of allClients()) {
const msgs = c.drainPushBuffer();
for (const m of msgs) drained.push(formatPush(m, c.meshSlug));
}
if (drained.length === 0) return text("No new messages.");
return text(
`${drained.length} new message(s):\n\n${drained.join("\n\n---\n\n")}`,
);
}
case "set_summary": {
const { summary } = (args ?? {}) as SetSummaryArgs;
if (!summary) return text("set_summary: `summary` required", true);
for (const c of allClients()) await c.setSummary(summary);
return text(
`Summary set: "${summary}" (visible to ${allClients().length} mesh(es)).`,
);
}
case "set_status": {
const { status } = (args ?? {}) as SetStatusArgs;
if (!status) return text("set_status: `status` required", true);
const s = status as PeerStatus;
for (const c of allClients()) await c.setStatus(s);
return text(`Status set to ${s} across ${allClients().length} mesh(es).`);
}
case "join_group": {
const { name: groupName, role } = (args ?? {}) as { name?: string; role?: string };
if (!groupName) return text("join_group: `name` required", true);
for (const c of allClients()) await c.joinGroup(groupName, role);
return text(`Joined @${groupName}${role ? ` as ${role}` : ""}`);
}
case "leave_group": {
const { name: groupName } = (args ?? {}) as { name?: string };
if (!groupName) return text("leave_group: `name` required", true);
for (const c of allClients()) await c.leaveGroup(groupName);
return text(`Left @${groupName}`);
}
default:
return text(`Unknown tool: ${name}`, true);
}
});
// Start broker clients for every joined mesh BEFORE MCP connects.
await startClients(config);
const transport = new StdioServerTransport();
await server.connect(transport);
// Wire WSS pushes → MCP channel notifications. Each inbound push on
// any mesh's broker connection becomes a <channel source="claudemesh">
// system reminder injected into Claude Code's context.
for (const client of allClients()) {
client.onPush(async (msg) => {
const fromPubkey = msg.senderPubkey || "";
// Resolve sender's display name from the cached peer list.
const fromName = fromPubkey
? await resolvePeerName(client, fromPubkey)
: "unknown";
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content,
meta: {
from_id: fromPubkey,
from_name: fromName,
mesh_slug: client.meshSlug,
mesh_id: client.meshId,
priority: msg.priority,
sent_at: msg.createdAt,
delivered_at: msg.receivedAt,
kind: msg.kind,
},
},
});
} catch {
/* channel push is best-effort; check_messages is the fallback */
}
});
}
const shutdown = (): void => {
stopAll();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}