Direct messages between peers are now end-to-end encrypted. The
broker only ever sees {nonce, ciphertext} — plaintext lives on the
two endpoints.
apps/cli/src/crypto/envelope.ts:
- encryptDirect(message, recipientPubkeyHex, senderSecretKeyHex)
→ {nonce, ciphertext} via crypto_box_easy, 24-byte fresh nonce
- decryptDirect(envelope, senderPubkeyHex, recipientSecretKeyHex)
→ plaintext or null (null on MAC failure / malformed input)
- ed25519 keys (from Step 17) are converted to X25519 on the fly via
crypto_sign_ed25519_{pk,sk}_to_curve25519 — one signing keypair
covers both signing + encryption roles.
BrokerClient.send():
- if targetSpec is a 64-hex pubkey → encrypt via crypto_box
- else (broadcast "*" or channel "#foo") → base64-wrapped plaintext
(shared-key encryption for channels lands in a later step)
InboundPush now carries:
- plaintext: string | null (decrypted body, null if decryption failed
OR it's a non-direct message)
- kind: "direct" | "broadcast" | "channel" | "unknown"
MCP check_messages formatter reads plaintext directly.
side-fixes pulled in during 18a:
- apps/broker/scripts/seed-test-mesh.ts now generates real ed25519
keypairs (the previous "aaaa…" / "bbbb…" fillers weren't valid
curve points, so crypto_sign_ed25519_pk_to_curve25519 rejected
them). Seed output now includes secretKey for each peer.
- apps/broker/src/broker.ts drainForMember wraps the atomic claim in
a CTE + outer ORDER BY so FIFO ordering is SQL-sourced, not
JS-sorted (Postgres microsecond timestamps collapse to the same
Date.getTime() milliseconds otherwise).
- vitest.config.ts fileParallelism: false — test files share
DB state via cleanupAllTestMeshes afterAll, so running them in
parallel caused one file's cleanup to race another's inserts.
- integration/health.test.ts "returns 200" now uses waitFullyHealthy
(a 200-only waiter) instead of waitHealthyOrAny — prevents a race
with the startup DB ping.
verified live:
- apps/cli/scripts/roundtrip.ts (direct A→B): ciphertext in DB is
opaque bytes (not base64-plaintext), decrypted correctly on arrival
- apps/cli/scripts/join-roundtrip.ts (full join → encrypted send):
PASSED
- 48/48 broker tests green
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
201 lines
6.7 KiB
TypeScript
201 lines
6.7 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.
|
|
*
|
|
* list_peers is stubbed at the CLI level — the broker's WS protocol
|
|
* does not yet carry a list-peers request type (Step 16). Until then,
|
|
* it returns a note.
|
|
*/
|
|
|
|
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), try every client;
|
|
* caller is expected to know which mesh the pubkey lives in.
|
|
* - If `to` starts with `#`, treat as channel on the first mesh.
|
|
* - Otherwise try to match a displayName (TODO — needs list_peers).
|
|
*
|
|
* For now the MVP: if only one mesh is joined, use that. Otherwise
|
|
* require the caller to prefix with `<mesh-slug>:`.
|
|
*/
|
|
function resolveClient(to: string): {
|
|
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"
|
|
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) return { client: match, targetSpec: rest };
|
|
}
|
|
// Single-mesh fast path.
|
|
if (clients.length === 1) {
|
|
return { client: clients[0]!, targetSpec: to };
|
|
}
|
|
return {
|
|
client: null,
|
|
targetSpec: to,
|
|
error: `multiple meshes joined; prefix target with "<mesh-slug>:" (joined: ${clients.map((c) => c.meshSlug).join(", ")})`,
|
|
};
|
|
}
|
|
|
|
function formatPush(p: InboundPush, meshSlug: string): string {
|
|
const body = p.plaintext ?? "(decryption failed)";
|
|
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.0" },
|
|
{
|
|
capabilities: { tools: {} },
|
|
instructions: `You are connected to claudemesh — a peer mesh for Claude Code sessions.
|
|
|
|
Use these tools to coordinate with peers on demand. Respond promptly when you receive messages (they're like someone tapping your shoulder).
|
|
|
|
Tools: send_message, list_peers, check_messages, set_summary, set_status.
|
|
|
|
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 <invite-link>` 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 } = 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 lines = clients.map(
|
|
(c) =>
|
|
`- ${c!.meshSlug} (${c!.status}, mesh ${c!.meshId.slice(0, 8)}…)`,
|
|
);
|
|
return text(
|
|
`Connected meshes:\n${lines.join("\n")}\n\n(list_peers WS protocol lands in Step 16; only mesh status is shown for now.)`,
|
|
);
|
|
}
|
|
|
|
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);
|
|
return text(
|
|
`set_summary: summary recorded locally ("${summary}"). (Broker WS protocol for summaries lands in Step 16.)`,
|
|
);
|
|
}
|
|
|
|
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).`);
|
|
}
|
|
|
|
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);
|
|
|
|
const shutdown = (): void => {
|
|
stopAll();
|
|
process.exit(0);
|
|
};
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGINT", shutdown);
|
|
}
|