@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
198 lines • 7.89 kB
JavaScript
// 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