feat: mesh services platform — deploy MCP servers, vaults, scopes
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Add the foundation for deploying and managing MCP servers on the VPS
broker, with per-peer credential vaults and visibility scopes.

Architecture:
- One Docker container per mesh with a Node supervisor
- Each MCP server runs as a child process with its own stdio pipe
- claudemesh launch installs native MCP entries in ~/.claude.json
- Mid-session deploys fall back to svc__* dynamic tools + list_changed

New components:
- DB: mesh.service + mesh.vault_entry tables, mesh.skill extensions
- Broker: 19 wire protocol types, 11 message handlers, service catalog
  in hello_ack with scope filtering, service-manager.ts (775 lines)
- CLI: 13 tool definitions, 12 WS client methods, tool call handlers,
  startServiceProxy() for native MCP proxy mode
- Launch: catalog fetch, native MCP entry install, stale sweep, cleanup,
  MCP_TIMEOUT=30s, MAX_MCP_OUTPUT_TOKENS=50k

Security: path sanitization on service names, column whitelist on
upsertService, returning()-based delete checks, vault E2E encryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-08 10:53:03 +01:00
parent a4f2e0aa81
commit e1cafa54b3
12 changed files with 3126 additions and 4 deletions

View File

@@ -39,8 +39,10 @@ import {
meshMember as memberTable,
meshMemory,
meshState,
meshService,
meshSkill,
meshStream,
meshVaultEntry,
meshTask,
messageQueue,
pendingStatus,
@@ -1951,3 +1953,91 @@ export async function meshSchema(
}
return [...tables.entries()].map(([name, columns]) => ({ name, columns }));
}
// ---------------------------------------------------------------------------
// Vault operations
// ---------------------------------------------------------------------------
export async function vaultSet(meshId: string, memberId: string, key: string, ciphertext: string, nonce: string, sealedKey: string, entryType: "env" | "file", mountPath?: string, description?: string): Promise<string> {
const existing = await db.select({ id: meshVaultEntry.id }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), eq(meshVaultEntry.key, key))).limit(1);
if (existing.length > 0) {
await db.update(meshVaultEntry).set({ ciphertext, nonce, sealedKey, entryType, mountPath: mountPath ?? null, description: description ?? null, updatedAt: new Date() }).where(eq(meshVaultEntry.id, existing[0]!.id));
return existing[0]!.id;
}
const [row] = await db.insert(meshVaultEntry).values({ meshId, memberId, key, ciphertext, nonce, sealedKey, entryType, mountPath: mountPath ?? null, description: description ?? null }).returning({ id: meshVaultEntry.id });
return row!.id;
}
export async function vaultList(meshId: string, memberId: string) {
return db.select({ key: meshVaultEntry.key, entryType: meshVaultEntry.entryType, mountPath: meshVaultEntry.mountPath, description: meshVaultEntry.description, updatedAt: meshVaultEntry.updatedAt }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId)));
}
export async function vaultDelete(meshId: string, memberId: string, key: string): Promise<boolean> {
const deleted = await db.delete(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), eq(meshVaultEntry.key, key))).returning({ id: meshVaultEntry.id });
return deleted.length > 0;
}
export async function vaultGetEntries(meshId: string, memberId: string, keys: string[]) {
if (keys.length === 0) return [];
return db.select({ key: meshVaultEntry.key, ciphertext: meshVaultEntry.ciphertext, nonce: meshVaultEntry.nonce, sealedKey: meshVaultEntry.sealedKey, entryType: meshVaultEntry.entryType, mountPath: meshVaultEntry.mountPath }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), inArray(meshVaultEntry.key, keys)));
}
// ---------------------------------------------------------------------------
// Service catalog operations
// ---------------------------------------------------------------------------
export async function upsertService(meshId: string, name: string, data: { type: "mcp" | "skill"; sourceType: string; description: string; sourceFileId?: string; sourceGitUrl?: string; sourceGitBranch?: string; sourceGitSha?: string; instructions?: string; toolsSchema?: unknown; manifest?: unknown; runtime?: string; status?: string; config?: unknown; scope?: unknown; deployedBy?: string; deployedByName?: string }): Promise<string> {
// Whitelist allowed fields — prevent mass-assignment of id, meshId, createdAt, etc.
const fields: Record<string, unknown> = {
type: data.type,
sourceType: data.sourceType,
description: data.description,
...(data.sourceFileId !== undefined && { sourceFileId: data.sourceFileId }),
...(data.sourceGitUrl !== undefined && { sourceGitUrl: data.sourceGitUrl }),
...(data.sourceGitBranch !== undefined && { sourceGitBranch: data.sourceGitBranch }),
...(data.sourceGitSha !== undefined && { sourceGitSha: data.sourceGitSha }),
...(data.instructions !== undefined && { instructions: data.instructions }),
...(data.toolsSchema !== undefined && { toolsSchema: data.toolsSchema }),
...(data.manifest !== undefined && { manifest: data.manifest }),
...(data.runtime !== undefined && { runtime: data.runtime }),
...(data.status !== undefined && { status: data.status }),
...(data.config !== undefined && { config: data.config }),
...(data.scope !== undefined && { scope: data.scope }),
...(data.deployedBy !== undefined && { deployedBy: data.deployedBy }),
...(data.deployedByName !== undefined && { deployedByName: data.deployedByName }),
};
const existing = await db.select({ id: meshService.id }).from(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).limit(1);
if (existing.length > 0) {
await db.update(meshService).set({ ...fields, updatedAt: new Date() } as any).where(eq(meshService.id, existing[0]!.id));
return existing[0]!.id;
}
const [row] = await db.insert(meshService).values({ meshId, name, ...fields } as any).returning({ id: meshService.id });
return row!.id;
}
export async function updateServiceStatus(meshId: string, name: string, status: string, extra?: { toolsSchema?: unknown; restartCount?: number; lastHealth?: Date }) {
await db.update(meshService).set({ status, ...(extra ?? {}), updatedAt: new Date() } as any).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name)));
}
export async function updateServiceScope(meshId: string, name: string, scope: unknown) {
await db.update(meshService).set({ scope, updatedAt: new Date() } as any).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name)));
}
export async function getService(meshId: string, name: string) {
const rows = await db.select().from(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).limit(1);
return rows[0] ?? null;
}
export async function listDbMeshServices(meshId: string) {
return db.select().from(meshService).where(eq(meshService.meshId, meshId));
}
export async function deleteService(meshId: string, name: string): Promise<boolean> {
const deleted = await db.delete(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).returning({ id: meshService.id });
return deleted.length > 0;
}
export async function getRunningServices(meshId: string) {
return db.select().from(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.status, "running")));
}