feat(cli+broker): expose mesh skills as MCP prompts and skill:// resources
Claudemesh MCP server now declares prompts:{} and resources:{} capabilities.
Mesh skills auto-appear as /claudemesh:skill-name slash commands in Claude Code
via prompts/list+get, and as skill://claudemesh/{name} resources for the
upcoming MCP_SKILLS protocol. share_skill accepts optional metadata (when_to_use,
allowed_tools, model, context, agent) stored in the manifest jsonb column.
Change notifications sent on share/remove so Claude Code refreshes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -720,6 +720,7 @@ export async function shareSkill(
|
|||||||
tags: string[],
|
tags: string[],
|
||||||
memberId?: string,
|
memberId?: string,
|
||||||
memberName?: string,
|
memberName?: string,
|
||||||
|
manifest?: unknown,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ id: meshSkill.id })
|
.select({ id: meshSkill.id })
|
||||||
@@ -734,6 +735,7 @@ export async function shareSkill(
|
|||||||
description,
|
description,
|
||||||
instructions,
|
instructions,
|
||||||
tags,
|
tags,
|
||||||
|
manifest: manifest ?? null,
|
||||||
authorMemberId: memberId ?? null,
|
authorMemberId: memberId ?? null,
|
||||||
authorName: memberName ?? null,
|
authorName: memberName ?? null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -750,6 +752,7 @@ export async function shareSkill(
|
|||||||
description,
|
description,
|
||||||
instructions,
|
instructions,
|
||||||
tags,
|
tags,
|
||||||
|
manifest: manifest ?? null,
|
||||||
authorMemberId: memberId ?? null,
|
authorMemberId: memberId ?? null,
|
||||||
authorName: memberName ?? null,
|
authorName: memberName ?? null,
|
||||||
})
|
})
|
||||||
@@ -770,6 +773,7 @@ export async function getSkill(
|
|||||||
instructions: string;
|
instructions: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
author: string;
|
author: string;
|
||||||
|
manifest: unknown;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
} | null> {
|
} | null> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -779,6 +783,7 @@ export async function getSkill(
|
|||||||
instructions: meshSkill.instructions,
|
instructions: meshSkill.instructions,
|
||||||
tags: meshSkill.tags,
|
tags: meshSkill.tags,
|
||||||
authorName: meshSkill.authorName,
|
authorName: meshSkill.authorName,
|
||||||
|
manifest: meshSkill.manifest,
|
||||||
createdAt: meshSkill.createdAt,
|
createdAt: meshSkill.createdAt,
|
||||||
})
|
})
|
||||||
.from(meshSkill)
|
.from(meshSkill)
|
||||||
@@ -793,6 +798,7 @@ export async function getSkill(
|
|||||||
instructions: r.instructions,
|
instructions: r.instructions,
|
||||||
tags: r.tags ?? [],
|
tags: r.tags ?? [],
|
||||||
author: r.authorName ?? "unknown",
|
author: r.authorName ?? "unknown",
|
||||||
|
manifest: r.manifest,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1800,13 +1806,18 @@ export async function joinMesh(args: {
|
|||||||
if (!claimed) return { ok: false, error: "invite_exhausted" };
|
if (!claimed) return { ok: false, error: "invite_exhausted" };
|
||||||
|
|
||||||
// 6. Insert the member with the role from the payload.
|
// 6. Insert the member with the role from the payload.
|
||||||
|
// Apply invite preset overrides (displayName, roleTag, groups, messageMode).
|
||||||
|
const preset = (inv.preset as any) ?? {};
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(memberTable)
|
.insert(memberTable)
|
||||||
.values({
|
.values({
|
||||||
meshId: invitePayload.mesh_id,
|
meshId: invitePayload.mesh_id,
|
||||||
peerPubkey,
|
peerPubkey,
|
||||||
displayName,
|
displayName: preset.displayName ?? displayName,
|
||||||
role: invitePayload.role,
|
role: invitePayload.role,
|
||||||
|
roleTag: preset.roleTag ?? null,
|
||||||
|
defaultGroups: preset.groups ?? [],
|
||||||
|
messageMode: preset.messageMode ?? "push",
|
||||||
})
|
})
|
||||||
.returning({ id: memberTable.id });
|
.returning({ id: memberTable.id });
|
||||||
if (!row) return { ok: false, error: "member_insert_failed" };
|
if (!row) return { ok: false, error: "member_insert_failed" };
|
||||||
@@ -1820,12 +1831,24 @@ export async function joinMesh(args: {
|
|||||||
export async function findMemberByPubkey(
|
export async function findMemberByPubkey(
|
||||||
meshId: string,
|
meshId: string,
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
): Promise<{ id: string; displayName: string; role: string } | null> {
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
roleTag: string | null;
|
||||||
|
defaultGroups: Array<{ name: string; role?: string }>;
|
||||||
|
messageMode: string | null;
|
||||||
|
dashboardUserId: string | null;
|
||||||
|
} | null> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
id: memberTable.id,
|
id: memberTable.id,
|
||||||
displayName: memberTable.displayName,
|
displayName: memberTable.displayName,
|
||||||
role: memberTable.role,
|
role: memberTable.role,
|
||||||
|
roleTag: memberTable.roleTag,
|
||||||
|
defaultGroups: memberTable.defaultGroups,
|
||||||
|
messageMode: memberTable.messageMode,
|
||||||
|
dashboardUserId: memberTable.dashboardUserId,
|
||||||
})
|
})
|
||||||
.from(memberTable)
|
.from(memberTable)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { and, eq, isNull, sql } from "drizzle-orm";
|
|||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { mesh, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh";
|
import { mesh, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh";
|
||||||
|
import { handleCliSync, type CliSyncRequest } from "./cli-sync";
|
||||||
|
import { updateMemberProfile, listMeshMembers, updateMeshSettings } from "./member-api";
|
||||||
import {
|
import {
|
||||||
claimTask,
|
claimTask,
|
||||||
completeTask,
|
completeTask,
|
||||||
@@ -585,6 +587,31 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CLI sync: browser OAuth → broker creates members
|
||||||
|
if (req.method === "POST" && req.url === "/cli-sync") {
|
||||||
|
handleCliSyncPost(req, res, started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member profile API
|
||||||
|
const memberPatchMatch = req.method === "PATCH" && req.url?.match(/^\/mesh\/([^/]+)\/member\/([^/]+)$/);
|
||||||
|
if (memberPatchMatch) {
|
||||||
|
handleMemberPatchPost(req, res, memberPatchMatch[1]!, memberPatchMatch[2]!, started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const membersListMatch = req.method === "GET" && req.url?.match(/^\/mesh\/([^/]+)\/members$/);
|
||||||
|
if (membersListMatch) {
|
||||||
|
handleMembersListGet(res, membersListMatch[1]!, started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meshSettingsMatch = req.method === "PATCH" && req.url?.match(/^\/mesh\/([^/]+)\/settings$/);
|
||||||
|
if (meshSettingsMatch) {
|
||||||
|
handleMeshSettingsPatch(req, res, meshSettingsMatch[1]!, started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Inbound webhook: POST /hook/:meshId/:secret
|
// Inbound webhook: POST /hook/:meshId/:secret
|
||||||
const webhookMatch = req.method === "POST" && req.url?.match(/^\/hook\/([^/]+)\/([^/]+)$/);
|
const webhookMatch = req.method === "POST" && req.url?.match(/^\/hook\/([^/]+)\/([^/]+)$/);
|
||||||
if (webhookMatch) {
|
if (webhookMatch) {
|
||||||
@@ -912,6 +939,100 @@ function broadcastToMesh(meshId: string, msg: WSPushMessage): number {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- CLI sync + member profile route handlers ---
|
||||||
|
|
||||||
|
function handleCliSyncPost(req: IncomingMessage, res: ServerResponse, started: number): void {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
let aborted = false;
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
if (aborted) return;
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > env.MAX_MESSAGE_BYTES) { aborted = true; writeJson(res, 413, { ok: false, error: "payload too large" }); req.destroy(); return; }
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on("end", async () => {
|
||||||
|
if (aborted) return;
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(Buffer.concat(chunks).toString()) as CliSyncRequest;
|
||||||
|
const result = await handleCliSync(body);
|
||||||
|
writeJson(res, result.ok ? 200 : 400, result);
|
||||||
|
log.info("cli-sync", { route: "POST /cli-sync", ok: result.ok, latency_ms: Date.now() - started });
|
||||||
|
} catch (e) {
|
||||||
|
writeJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
log.error("cli-sync error", { error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMemberPatchPost(req: IncomingMessage, res: ServerResponse, meshId: string, memberId: string, started: number): void {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
let aborted = false;
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
if (aborted) return;
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > env.MAX_MESSAGE_BYTES) { aborted = true; writeJson(res, 413, { ok: false, error: "payload too large" }); req.destroy(); return; }
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on("end", async () => {
|
||||||
|
if (aborted) return;
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(Buffer.concat(chunks).toString());
|
||||||
|
// Auth: callerMemberId from X-Member-Id header (dashboard or CLI provides this)
|
||||||
|
const callerMemberId = req.headers["x-member-id"] as string | undefined;
|
||||||
|
if (!callerMemberId) { writeJson(res, 401, { ok: false, error: "X-Member-Id header required" }); return; }
|
||||||
|
const result = await updateMemberProfile(meshId, memberId, callerMemberId, body);
|
||||||
|
writeJson(res, result.ok ? 200 : 400, result);
|
||||||
|
// Push profile_updated to active WS connections for this member
|
||||||
|
if (result.ok && result.changes) {
|
||||||
|
for (const [pid, conn] of connections) {
|
||||||
|
if (conn.meshId === meshId && conn.memberId === memberId) {
|
||||||
|
sendToPeer(pid, { type: "push", subtype: "system", event: "profile_updated", eventData: result.changes, messageId: crypto.randomUUID(), meshId, senderPubkey: "system", priority: "low", nonce: "", ciphertext: "", createdAt: new Date().toISOString() } as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("member-patch", { route: `PATCH /mesh/${meshId}/member/${memberId}`, ok: result.ok, latency_ms: Date.now() - started });
|
||||||
|
} catch (e) {
|
||||||
|
writeJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMembersListGet(res: ServerResponse, meshId: string, started: number): void {
|
||||||
|
listMeshMembers(meshId).then((result) => {
|
||||||
|
writeJson(res, result.ok ? 200 : 400, result);
|
||||||
|
log.info("members-list", { route: `GET /mesh/${meshId}/members`, ok: result.ok, count: result.ok ? result.members.length : 0, latency_ms: Date.now() - started });
|
||||||
|
}).catch((e) => {
|
||||||
|
writeJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMeshSettingsPatch(req: IncomingMessage, res: ServerResponse, meshId: string, started: number): void {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
let aborted = false;
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
if (aborted) return;
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > env.MAX_MESSAGE_BYTES) { aborted = true; writeJson(res, 413, { ok: false, error: "payload too large" }); req.destroy(); return; }
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on("end", async () => {
|
||||||
|
if (aborted) return;
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(Buffer.concat(chunks).toString());
|
||||||
|
const callerMemberId = req.headers["x-member-id"] as string | undefined;
|
||||||
|
if (!callerMemberId) { writeJson(res, 401, { ok: false, error: "X-Member-Id header required" }); return; }
|
||||||
|
const result = await updateMeshSettings(meshId, callerMemberId, body);
|
||||||
|
writeJson(res, result.ok ? 200 : 400, result);
|
||||||
|
log.info("mesh-settings", { route: `PATCH /mesh/${meshId}/settings`, ok: result.ok, latency_ms: Date.now() - started });
|
||||||
|
} catch (e) {
|
||||||
|
writeJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleWebhookPost(
|
function handleWebhookPost(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
@@ -1159,13 +1280,23 @@ async function handleHello(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load mesh for selfEditable policy (non-fatal if fails).
|
||||||
|
let meshPolicy: Record<string, unknown> | undefined;
|
||||||
|
try {
|
||||||
|
const [m] = await db
|
||||||
|
.select({ selfEditable: mesh.selfEditable })
|
||||||
|
.from(mesh)
|
||||||
|
.where(eq(mesh.id, hello.meshId));
|
||||||
|
if (m?.selfEditable) meshPolicy = { selfEditable: m.selfEditable };
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
|
||||||
// Attempt to restore persisted state from a previous session.
|
// Attempt to restore persisted state from a previous session.
|
||||||
const saved = await restorePeerState(hello.meshId, member.id);
|
const saved = await restorePeerState(hello.meshId, member.id);
|
||||||
const helloHasGroups = hello.groups && hello.groups.length > 0;
|
const helloHasGroups = hello.groups && hello.groups.length > 0;
|
||||||
// Hello groups take precedence; fall back to restored groups.
|
// Priority: hello groups > restored groups > member default groups.
|
||||||
const initialGroups = helloHasGroups
|
const initialGroups = helloHasGroups
|
||||||
? hello.groups!
|
? hello.groups!
|
||||||
: (saved?.groups ?? []);
|
: (saved?.groups?.length ? saved.groups : (member.defaultGroups ?? []));
|
||||||
const presenceId = await connectPresence({
|
const presenceId = await connectPresence({
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
sessionId: hello.sessionId,
|
sessionId: hello.sessionId,
|
||||||
@@ -1213,6 +1344,12 @@ async function handleHello(
|
|||||||
return {
|
return {
|
||||||
presenceId,
|
presenceId,
|
||||||
memberDisplayName: effectiveDisplayName,
|
memberDisplayName: effectiveDisplayName,
|
||||||
|
memberProfile: {
|
||||||
|
roleTag: member.roleTag,
|
||||||
|
groups: member.defaultGroups ?? [],
|
||||||
|
messageMode: member.messageMode ?? "push",
|
||||||
|
},
|
||||||
|
meshPolicy,
|
||||||
restored: saved ? true : undefined,
|
restored: saved ? true : undefined,
|
||||||
lastSummary: saved?.lastSummary,
|
lastSummary: saved?.lastSummary,
|
||||||
lastSeenAt: saved?.lastSeenAt?.toISOString(),
|
lastSeenAt: saved?.lastSeenAt?.toISOString(),
|
||||||
@@ -1333,6 +1470,8 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
type: "hello_ack",
|
type: "hello_ack",
|
||||||
presenceId: result.presenceId,
|
presenceId: result.presenceId,
|
||||||
memberDisplayName: result.memberDisplayName,
|
memberDisplayName: result.memberDisplayName,
|
||||||
|
memberProfile: result.memberProfile,
|
||||||
|
...(result.meshPolicy ? { meshPolicy: result.meshPolicy } : {}),
|
||||||
};
|
};
|
||||||
if (result.restored) {
|
if (result.restored) {
|
||||||
ackPayload.restored = true;
|
ackPayload.restored = true;
|
||||||
@@ -3053,6 +3192,7 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
sk.tags ?? [],
|
sk.tags ?? [],
|
||||||
memberInfo?.id,
|
memberInfo?.id,
|
||||||
memberInfo?.displayName,
|
memberInfo?.displayName,
|
||||||
|
(sk as any).manifest,
|
||||||
);
|
);
|
||||||
sendToPeer(presenceId, {
|
sendToPeer(presenceId, {
|
||||||
type: "skill_ack",
|
type: "skill_ack",
|
||||||
@@ -3075,6 +3215,7 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
instructions: skill.instructions,
|
instructions: skill.instructions,
|
||||||
tags: skill.tags,
|
tags: skill.tags,
|
||||||
author: skill.author,
|
author: skill.author,
|
||||||
|
manifest: skill.manifest,
|
||||||
createdAt: skill.createdAt.toISOString(),
|
createdAt: skill.createdAt.toISOString(),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|||||||
import {
|
import {
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
|
ListPromptsRequestSchema,
|
||||||
|
GetPromptRequestSchema,
|
||||||
|
ListResourcesRequestSchema,
|
||||||
|
ReadResourceRequestSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { TOOLS } from "./tools";
|
import { TOOLS } from "./tools";
|
||||||
import { loadConfig } from "../state/config";
|
import { loadConfig } from "../state/config";
|
||||||
@@ -164,6 +168,8 @@ export async function startMcpServer(): Promise<void> {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
experimental: { "claude/channel": {} },
|
experimental: { "claude/channel": {} },
|
||||||
tools: {},
|
tools: {},
|
||||||
|
prompts: {},
|
||||||
|
resources: {},
|
||||||
},
|
},
|
||||||
instructions: `## Identity
|
instructions: `## Identity
|
||||||
You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
|
You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
|
||||||
@@ -294,6 +300,111 @@ Your message mode is "${messageMode}".
|
|||||||
tools: TOOLS,
|
tools: TOOLS,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// --- MCP Prompts: expose mesh skills as slash commands ---
|
||||||
|
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
||||||
|
const client = allClients()[0];
|
||||||
|
if (!client) return { prompts: [] };
|
||||||
|
const skills = await client.listSkills();
|
||||||
|
return {
|
||||||
|
prompts: skills.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
arguments: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
||||||
|
const { name, arguments: promptArgs } = req.params;
|
||||||
|
const client = allClients()[0];
|
||||||
|
if (!client) throw new Error("Not connected to any mesh");
|
||||||
|
const skill = await client.getSkill(name);
|
||||||
|
if (!skill) throw new Error(`Skill "${name}" not found in the mesh`);
|
||||||
|
|
||||||
|
// Build the prompt content — include frontmatter if manifest has metadata
|
||||||
|
let content = skill.instructions;
|
||||||
|
const manifest = (skill as any).manifest;
|
||||||
|
if (manifest && typeof manifest === "object") {
|
||||||
|
const fm: string[] = ["---"];
|
||||||
|
if (manifest.description) fm.push(`description: "${manifest.description}"`);
|
||||||
|
if (manifest.when_to_use) fm.push(`when_to_use: "${manifest.when_to_use}"`);
|
||||||
|
if (manifest.allowed_tools?.length) fm.push(`allowed-tools:\n${manifest.allowed_tools.map((t: string) => ` - ${t}`).join("\n")}`);
|
||||||
|
if (manifest.model) fm.push(`model: ${manifest.model}`);
|
||||||
|
if (manifest.context) fm.push(`context: ${manifest.context}`);
|
||||||
|
if (manifest.agent) fm.push(`agent: ${manifest.agent}`);
|
||||||
|
if (manifest.user_invocable === false) fm.push(`user-invocable: false`);
|
||||||
|
if (manifest.argument_hint) fm.push(`argument-hint: "${manifest.argument_hint}"`);
|
||||||
|
fm.push("---\n");
|
||||||
|
if (fm.length > 3) content = fm.join("\n") + content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: skill.description,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user" as const,
|
||||||
|
content: { type: "text" as const, text: content },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- MCP Resources: expose mesh skills as skill:// resources ---
|
||||||
|
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||||
|
const client = allClients()[0];
|
||||||
|
if (!client) return { resources: [] };
|
||||||
|
const skills = await client.listSkills();
|
||||||
|
return {
|
||||||
|
resources: skills.map((s) => ({
|
||||||
|
uri: `skill://claudemesh/${encodeURIComponent(s.name)}`,
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
||||||
|
const { uri } = req.params;
|
||||||
|
// Parse skill://claudemesh/{name}
|
||||||
|
const match = uri.match(/^skill:\/\/claudemesh\/(.+)$/);
|
||||||
|
if (!match) throw new Error(`Unknown resource URI: ${uri}`);
|
||||||
|
const name = decodeURIComponent(match[1]!);
|
||||||
|
const client = allClients()[0];
|
||||||
|
if (!client) throw new Error("Not connected to any mesh");
|
||||||
|
const skill = await client.getSkill(name);
|
||||||
|
if (!skill) throw new Error(`Skill "${name}" not found`);
|
||||||
|
|
||||||
|
// Build full markdown with frontmatter for Claude Code's parseSkillFrontmatterFields
|
||||||
|
const manifest = (skill as any).manifest;
|
||||||
|
const fmLines: string[] = ["---"];
|
||||||
|
fmLines.push(`name: ${skill.name}`);
|
||||||
|
fmLines.push(`description: "${skill.description}"`);
|
||||||
|
if (skill.tags.length) fmLines.push(`tags: [${skill.tags.join(", ")}]`);
|
||||||
|
if (manifest && typeof manifest === "object") {
|
||||||
|
if (manifest.when_to_use) fmLines.push(`when_to_use: "${manifest.when_to_use}"`);
|
||||||
|
if (manifest.allowed_tools?.length) fmLines.push(`allowed-tools:\n${manifest.allowed_tools.map((t: string) => ` - ${t}`).join("\n")}`);
|
||||||
|
if (manifest.model) fmLines.push(`model: ${manifest.model}`);
|
||||||
|
if (manifest.context) fmLines.push(`context: ${manifest.context}`);
|
||||||
|
if (manifest.agent) fmLines.push(`agent: ${manifest.agent}`);
|
||||||
|
if (manifest.user_invocable === false) fmLines.push(`user-invocable: false`);
|
||||||
|
if (manifest.argument_hint) fmLines.push(`argument-hint: "${manifest.argument_hint}"`);
|
||||||
|
}
|
||||||
|
fmLines.push("---\n");
|
||||||
|
|
||||||
|
const fullContent = fmLines.join("\n") + skill.instructions;
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
uri,
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
text: fullContent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||||
const { name, arguments: args } = req.params;
|
const { name, arguments: args } = req.params;
|
||||||
|
|
||||||
@@ -1054,13 +1165,32 @@ Your message mode is "${messageMode}".
|
|||||||
|
|
||||||
// --- Skills ---
|
// --- Skills ---
|
||||||
case "share_skill": {
|
case "share_skill": {
|
||||||
const { name: skillName, description: skillDesc, instructions: skillInstr, tags: skillTags } = (args ?? {}) as { name?: string; description?: string; instructions?: string; tags?: string[] };
|
const {
|
||||||
|
name: skillName, description: skillDesc, instructions: skillInstr, tags: skillTags,
|
||||||
|
when_to_use, allowed_tools, model, context: skillContext, agent, user_invocable, argument_hint,
|
||||||
|
} = (args ?? {}) as {
|
||||||
|
name?: string; description?: string; instructions?: string; tags?: string[];
|
||||||
|
when_to_use?: string; allowed_tools?: string[]; model?: string; context?: string;
|
||||||
|
agent?: string; user_invocable?: boolean; argument_hint?: string;
|
||||||
|
};
|
||||||
if (!skillName || !skillDesc || !skillInstr) return text("share_skill: `name`, `description`, and `instructions` required", true);
|
if (!skillName || !skillDesc || !skillInstr) return text("share_skill: `name`, `description`, and `instructions` required", true);
|
||||||
const client = allClients()[0];
|
const client = allClients()[0];
|
||||||
if (!client) return text("share_skill: not connected", true);
|
if (!client) return text("share_skill: not connected", true);
|
||||||
const result = await client.shareSkill(skillName, skillDesc, skillInstr, skillTags);
|
// Build manifest from optional metadata fields
|
||||||
|
const manifest: Record<string, unknown> = {};
|
||||||
|
if (when_to_use) manifest.when_to_use = when_to_use;
|
||||||
|
if (allowed_tools?.length) manifest.allowed_tools = allowed_tools;
|
||||||
|
if (model) manifest.model = model;
|
||||||
|
if (skillContext) manifest.context = skillContext;
|
||||||
|
if (agent) manifest.agent = agent;
|
||||||
|
if (user_invocable === false) manifest.user_invocable = false;
|
||||||
|
if (argument_hint) manifest.argument_hint = argument_hint;
|
||||||
|
const result = await client.shareSkill(skillName, skillDesc, skillInstr, skillTags, Object.keys(manifest).length > 0 ? manifest : undefined);
|
||||||
if (!result) return text("share_skill: broker did not acknowledge", true);
|
if (!result) return text("share_skill: broker did not acknowledge", true);
|
||||||
return text(`Skill "${skillName}" published to the mesh.`);
|
// Notify prompts changed so Claude Code refreshes slash commands
|
||||||
|
server.notification({ method: "notifications/prompts/list_changed" });
|
||||||
|
server.notification({ method: "notifications/resources/list_changed" });
|
||||||
|
return text(`Skill "${skillName}" published to the mesh. It will appear as /claudemesh:${skillName} in Claude Code.`);
|
||||||
}
|
}
|
||||||
case "get_skill": {
|
case "get_skill": {
|
||||||
const { name: gsName } = (args ?? {}) as { name?: string };
|
const { name: gsName } = (args ?? {}) as { name?: string };
|
||||||
@@ -1069,13 +1199,24 @@ Your message mode is "${messageMode}".
|
|||||||
if (!client) return text("get_skill: not connected", true);
|
if (!client) return text("get_skill: not connected", true);
|
||||||
const skill = await client.getSkill(gsName);
|
const skill = await client.getSkill(gsName);
|
||||||
if (!skill) return text(`Skill "${gsName}" not found in the mesh.`);
|
if (!skill) return text(`Skill "${gsName}" not found in the mesh.`);
|
||||||
|
const manifest = skill.manifest as Record<string, unknown> | null | undefined;
|
||||||
|
const metaLines: string[] = [];
|
||||||
|
if (manifest) {
|
||||||
|
if (manifest.when_to_use) metaLines.push(`**When to use:** ${manifest.when_to_use}`);
|
||||||
|
if (manifest.allowed_tools) metaLines.push(`**Allowed tools:** ${(manifest.allowed_tools as string[]).join(", ")}`);
|
||||||
|
if (manifest.model) metaLines.push(`**Model:** ${manifest.model}`);
|
||||||
|
if (manifest.context) metaLines.push(`**Context:** ${manifest.context}`);
|
||||||
|
if (manifest.agent) metaLines.push(`**Agent:** ${manifest.agent}`);
|
||||||
|
}
|
||||||
return text(
|
return text(
|
||||||
`# Skill: ${skill.name}\n\n` +
|
`# Skill: ${skill.name}\n\n` +
|
||||||
`**Description:** ${skill.description}\n` +
|
`**Description:** ${skill.description}\n` +
|
||||||
`**Author:** ${skill.author}\n` +
|
`**Author:** ${skill.author}\n` +
|
||||||
`**Tags:** ${skill.tags.length ? skill.tags.join(", ") : "none"}\n` +
|
`**Tags:** ${skill.tags.length ? skill.tags.join(", ") : "none"}\n` +
|
||||||
`**Created:** ${skill.createdAt}\n\n` +
|
`**Created:** ${skill.createdAt}\n` +
|
||||||
`---\n\n` +
|
`**Slash command:** /claudemesh:${skill.name}\n` +
|
||||||
|
(metaLines.length ? metaLines.join("\n") + "\n" : "") +
|
||||||
|
`\n---\n\n` +
|
||||||
`## Instructions\n\n${skill.instructions}`,
|
`## Instructions\n\n${skill.instructions}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1096,6 +1237,10 @@ Your message mode is "${messageMode}".
|
|||||||
const client = allClients()[0];
|
const client = allClients()[0];
|
||||||
if (!client) return text("remove_skill: not connected", true);
|
if (!client) return text("remove_skill: not connected", true);
|
||||||
const removed = await client.removeSkill(rsName);
|
const removed = await client.removeSkill(rsName);
|
||||||
|
if (removed) {
|
||||||
|
server.notification({ method: "notifications/prompts/list_changed" });
|
||||||
|
server.notification({ method: "notifications/resources/list_changed" });
|
||||||
|
}
|
||||||
return text(removed ? `Skill "${rsName}" removed.` : `Skill "${rsName}" not found.`, !removed);
|
return text(removed ? `Skill "${rsName}" removed.` : `Skill "${rsName}" not found.`, !removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -763,18 +763,29 @@ export const TOOLS: Tool[] = [
|
|||||||
{
|
{
|
||||||
name: "share_skill",
|
name: "share_skill",
|
||||||
description:
|
description:
|
||||||
"Publish a reusable skill to the mesh. Other peers can discover and load it. If a skill with the same name exists, it is updated.",
|
"Publish a reusable skill to the mesh. Other peers can discover and load it as a slash command. If a skill with the same name exists, it is updated. Skills are automatically exposed as MCP prompts and skill:// resources for native Claude Code integration.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist')" },
|
name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist'). Becomes the slash command name." },
|
||||||
description: { type: "string", description: "Short description of what the skill does" },
|
description: { type: "string", description: "Short description of what the skill does" },
|
||||||
instructions: { type: "string", description: "Full instructions/prompt that a peer loads to acquire this capability" },
|
instructions: { type: "string", description: "Full instructions/prompt markdown. Can include frontmatter (---) block." },
|
||||||
tags: {
|
tags: {
|
||||||
type: "array",
|
type: "array",
|
||||||
items: { type: "string" },
|
items: { type: "string" },
|
||||||
description: "Tags for discoverability",
|
description: "Tags for discoverability",
|
||||||
},
|
},
|
||||||
|
when_to_use: { type: "string", description: "Detailed description of when Claude should auto-invoke this skill" },
|
||||||
|
allowed_tools: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Tool names this skill is allowed to use (e.g. ['Bash', 'Read', 'Edit'])",
|
||||||
|
},
|
||||||
|
model: { type: "string", description: "Model override (e.g. 'sonnet', 'opus', 'haiku')" },
|
||||||
|
context: { type: "string", enum: ["inline", "fork"], description: "Execution context: 'inline' (default) or 'fork' (sub-agent)" },
|
||||||
|
agent: { type: "string", description: "Agent type when forked (e.g. 'general-purpose')" },
|
||||||
|
user_invocable: { type: "boolean", description: "Whether users can invoke via /skill-name (default: true)" },
|
||||||
|
argument_hint: { type: "string", description: "Hint text for arguments (e.g. '<file-path>')" },
|
||||||
},
|
},
|
||||||
required: ["name", "description", "instructions"],
|
required: ["name", "description", "instructions"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1103,11 +1103,11 @@ export class BrokerClient {
|
|||||||
|
|
||||||
// --- Skills ---
|
// --- Skills ---
|
||||||
private skillAckResolvers = new Map<string, { resolve: (result: { name: string; action: string } | null) => void; timer: NodeJS.Timeout }>();
|
private skillAckResolvers = new Map<string, { resolve: (result: { name: string; action: string } | null) => void; timer: NodeJS.Timeout }>();
|
||||||
private skillDataResolvers = new Map<string, { resolve: (skill: { name: string; description: string; instructions: string; tags: string[]; author: string; createdAt: string } | null) => void; timer: NodeJS.Timeout }>();
|
private skillDataResolvers = new Map<string, { resolve: (skill: { name: string; description: string; instructions: string; tags: string[]; author: string; manifest?: unknown; createdAt: string } | null) => void; timer: NodeJS.Timeout }>();
|
||||||
private skillListResolvers = new Map<string, { resolve: (skills: Array<{ name: string; description: string; tags: string[]; author: string; createdAt: string }>) => void; timer: NodeJS.Timeout }>();
|
private skillListResolvers = new Map<string, { resolve: (skills: Array<{ name: string; description: string; tags: string[]; author: string; createdAt: string }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
|
||||||
/** Publish a reusable skill to the mesh. */
|
/** Publish a reusable skill to the mesh. */
|
||||||
async shareSkill(name: string, description: string, instructions: string, tags?: string[]): Promise<{ ok: boolean; action?: string } | null> {
|
async shareSkill(name: string, description: string, instructions: string, tags?: string[], manifest?: Record<string, unknown>): Promise<{ ok: boolean; action?: string } | null> {
|
||||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const reqId = this.makeReqId();
|
const reqId = this.makeReqId();
|
||||||
@@ -1116,12 +1116,12 @@ export class BrokerClient {
|
|||||||
}, timer: setTimeout(() => {
|
}, timer: setTimeout(() => {
|
||||||
if (this.skillAckResolvers.delete(reqId)) resolve(null);
|
if (this.skillAckResolvers.delete(reqId)) resolve(null);
|
||||||
}, 5_000) });
|
}, 5_000) });
|
||||||
this.ws!.send(JSON.stringify({ type: "share_skill", name, description, instructions, tags, _reqId: reqId }));
|
this.ws!.send(JSON.stringify({ type: "share_skill", name, description, instructions, tags, manifest, _reqId: reqId }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load a skill's full instructions by name. */
|
/** Load a skill's full instructions by name. */
|
||||||
async getSkill(name: string): Promise<{ name: string; description: string; instructions: string; tags: string[]; author: string; createdAt: string } | null> {
|
async getSkill(name: string): Promise<{ name: string; description: string; instructions: string; tags: string[]; author: string; manifest?: unknown; createdAt: string } | null> {
|
||||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const reqId = this.makeReqId();
|
const reqId = this.makeReqId();
|
||||||
|
|||||||
Reference in New Issue
Block a user