UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

198 lines • 7.89 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * SEP-2640 skills extension — ADR-328. * * The one place that decides what a served `skill://withpica/<name>/SKILL.md` * says, and the `skills/list` / `skills/get` entry that describes it. Both * transports (stdio `resources/index.ts`, HTTP `app/api/mcp/route.ts`) read * skill text through `renderSkillMd`, and build entries through `skillEntry`, * so the digest and the bytes it covers come from the same function. * * The entry's `frontmatter` and the served YAML are serialised from one * object; `__tests__/skills/sep2640.test.ts` parses the served text back and * hashes it to check the two agree. * * Served frontmatter follows the Agent Skills spec (agentskills.io): only * name / description / compatibility at the top level; PICA's own fields go * under `metadata` as strings. The source SKILL.md files and the * `org_custom_skills` table keep their own shape — this is the served form. */ import { createHash } from "node:crypto"; import { z } from "zod"; import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; export const SKILLS_EXTENSION_ID = "io.modelcontextprotocol/skills"; /** Spread into `capabilities.extensions`. No `directoryRead`: every skill is one file. */ export const SKILLS_EXTENSION_CAPABILITY = { [SKILLS_EXTENSION_ID]: {} }; const URI_PREFIX = "skill://withpica/"; const URI_RE = /^skill:\/\/withpica\/([a-z0-9-]+)\/SKILL\.md$/; /** Agent Skills naming rule: 1–64 chars, a-z0-9 and single inner hyphens. */ const AGENT_SKILLS_NAME_RE = /^(?=.{1,64}$)[a-z0-9]+(-[a-z0-9]+)*$/; const MAX_DESCRIPTION = 1024; const COMPATIBILITY = "Requires the withPICA MCP server (pica_* tools)."; export class SkillEntryNotFoundError extends Error { uri; constructor(uri) { super(`Not a served skill: ${uri}`); this.uri = uri; this.name = "SkillEntryNotFoundError"; } } export function skillUri(name) { return `${URI_PREFIX}${name}/SKILL.md`; } /** Skill name from a `skill://withpica/<name>/SKILL.md` URI, or null. */ export function skillNameFromUri(uri) { return uri.match(URI_RE)?.[1] ?? null; } export function skillFrontmatter(skill, origin) { const metadata = { "pica-audience": skill.audience, "pica-output": skill.output, "pica-triggers": skill.triggers.join("\n"), "pica-tools-required": skill.tools_required.join("\n"), }; if (skill.definition_of_done) { metadata["pica-definition-of-done"] = skill.definition_of_done; } if (skill.when_not_to_use) { metadata["pica-when-not-to-use"] = skill.when_not_to_use; } metadata["pica-origin"] = origin; return { name: skill.name, description: skill.description, compatibility: COMPATIBILITY, metadata, }; } /** * Characters JSON.stringify leaves raw that YAML forbids or reads differently: * DEL and the C1 controls (NEL U+0085 is a line break in YAML 1.1), the line / * paragraph separators, BOM and the non-characters U+FFFE / U+FFFF. */ const YAML_UNSAFE = /[\u007F-\u009F\u2028\u2029\uFEFF\uFFFE\uFFFF]/g; /** A JSON string literal with YAML-unsafe characters as \uXXXX escapes. */ function quote(value) { return JSON.stringify(value).replace(YAML_UNSAFE, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")); } /** * A skill name that YAML reads back as the same string when written plain: * starts with a letter (so never a number or date) and is not one of the * YAML 1.1 / 1.2 boolean or null words. */ const PLAIN_SAFE_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; const YAML_RESERVED_WORDS = new Set([ "y", "n", "yes", "no", "on", "off", "true", "false", "null", ]); /** * `name` is written plain when PLAIN_SAFE_NAME allows it, as the Agent Skills * examples do. Clients that read `name:` out of the text with a pattern, not a * YAML parser, then still find it. Every other scalar goes through `quote`: a * JSON string with the YAML-unsafe characters escaped, which is a valid YAML * double-quoted scalar. sep2640.test.ts parses the served text with a YAML * parser and compares it to the entry, including those characters and the * reserved-word names. */ function yamlName(name) { return PLAIN_SAFE_NAME.test(name) && !YAML_RESERVED_WORDS.has(name) ? name : quote(name); } function toYaml(fm) { const lines = [ `name: ${yamlName(fm.name)}`, `description: ${quote(fm.description)}`, `compatibility: ${quote(fm.compatibility)}`, "metadata:", ...Object.entries(fm.metadata).map(([k, v]) => ` ${quote(k)}: ${quote(v)}`), ]; return lines.join("\n") + "\n"; } /** The exact text served by `resources/read` for this skill's SKILL.md. */ export function renderSkillMd(skill, origin) { return `---\n${toYaml(skillFrontmatter(skill, origin))}---\n\n${skill.body}`; } function skillEntry(skill, origin) { const bytes = Buffer.from(renderSkillMd(skill, origin), "utf8"); const uri = skillUri(skill.name); return { uri, frontmatter: skillFrontmatter(skill, origin), resources: [ { uri, digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, size: bytes.length, }, ], }; } function isListable(skill) { return (AGENT_SKILLS_NAME_RE.test(skill.name) && skill.description.length > 0 && skill.description.length <= MAX_DESCRIPTION); } /** * Entries for `skills/list`. A skill whose name or description breaks the * Agent Skills rules (custom-skill names allow `--` and a trailing hyphen — * lib/services/custom-skills.ts NAME_RE) is left out of the * listing — SEP-2640 permits a partial listing — but stays gettable by URI. */ export function listSkillEntries(registry) { return registry .listNames() .map((name) => registry.getSkill(name)) .filter(isListable) .map((skill) => skillEntry(skill, registry.originOf(skill.name))); } /** Entry for `skills/get`. Throws SkillEntryNotFoundError (→ -32602). */ export function getSkillEntry(registry, uri) { const name = skillNameFromUri(uri); const origin = name ? registry.originOf(name) : null; if (!name || !origin) throw new SkillEntryNotFoundError(uri); return skillEntry(registry.getSkill(name), origin); } export const SkillsListRequestSchema = z.object({ method: z.literal("skills/list"), params: z.object({ cursor: z.string().optional() }).passthrough().optional(), }); // `uri` is checked in skillsGetResult, not here: a schema failure surfaces as // -32603 from the SDK, and SEP-2640 wants -32602 for a bad request. export const SkillsGetRequestSchema = z.object({ method: z.literal("skills/get"), params: z.object({ uri: z.unknown() }).passthrough().optional(), }); /** `skills/list` result. One page: PICA's catalog is small and fixed per caller. */ export function skillsListResult(entries) { return { resultType: "complete", skills: entries }; } /** * `skills/get` result. A missing, unknown or malformed URI is -32602 (Invalid * params), the code SEP-2640 requires; pinned by sep2640-protocol.test.ts. * (PICA's resources/read still answers -32603 for an unknown URI: ResourceError * carries a string code, which the SDK maps to InternalError.) */ export function skillsGetResult(uri, get) { if (typeof uri !== "string") { throw new McpError(ErrorCode.InvalidParams, "skills/get requires a uri"); } try { return { resultType: "complete", skill: get(uri) }; } catch (error) { if (error instanceof SkillEntryNotFoundError) { throw new McpError(ErrorCode.InvalidParams, error.message); } throw error; } } //# sourceMappingURL=sep2640.js.map