@withpica/mcp-server-directory 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +2 -1
  2. package/scripts/build-skills.ts +192 -0
  3. package/src/__tests__/skills/skills-registry.test.ts +63 -0
  4. package/src/__tests__/skills/skills-tools.test.ts +74 -0
  5. package/src/prompts/public-question-atlas.ts +17 -0
  6. package/src/server.ts +123 -11
  7. package/src/skills/find-music-for-sync-brief/SKILL.md +89 -0
  8. package/src/skills/index.ts +63 -0
  9. package/src/skills/skills.generated.ts +33 -0
  10. package/src/tools/index.ts +3 -0
  11. package/src/tools/skills.ts +146 -0
  12. package/dist/client.d.ts +0 -12
  13. package/dist/client.d.ts.map +0 -1
  14. package/dist/client.js +0 -93
  15. package/dist/client.js.map +0 -1
  16. package/dist/config.d.ts +0 -9
  17. package/dist/config.d.ts.map +0 -1
  18. package/dist/config.js +0 -15
  19. package/dist/config.js.map +0 -1
  20. package/dist/index.d.ts +0 -3
  21. package/dist/index.d.ts.map +0 -1
  22. package/dist/index.js +0 -28
  23. package/dist/index.js.map +0 -1
  24. package/dist/prompts/index.d.ts +0 -32
  25. package/dist/prompts/index.d.ts.map +0 -1
  26. package/dist/prompts/index.js +0 -165
  27. package/dist/prompts/index.js.map +0 -1
  28. package/dist/prompts/public-question-atlas.d.ts +0 -121
  29. package/dist/prompts/public-question-atlas.d.ts.map +0 -1
  30. package/dist/prompts/public-question-atlas.js +0 -404
  31. package/dist/prompts/public-question-atlas.js.map +0 -1
  32. package/dist/resources/llms-primer.d.ts +0 -2
  33. package/dist/resources/llms-primer.d.ts.map +0 -1
  34. package/dist/resources/llms-primer.js +0 -35
  35. package/dist/resources/llms-primer.js.map +0 -1
  36. package/dist/server.d.ts +0 -13
  37. package/dist/server.d.ts.map +0 -1
  38. package/dist/server.js +0 -104
  39. package/dist/server.js.map +0 -1
  40. package/dist/tools/chain.d.ts +0 -12
  41. package/dist/tools/chain.d.ts.map +0 -1
  42. package/dist/tools/chain.js +0 -109
  43. package/dist/tools/chain.js.map +0 -1
  44. package/dist/tools/index.d.ts +0 -35
  45. package/dist/tools/index.d.ts.map +0 -1
  46. package/dist/tools/index.js +0 -45
  47. package/dist/tools/index.js.map +0 -1
  48. package/dist/tools/people.d.ts +0 -13
  49. package/dist/tools/people.d.ts.map +0 -1
  50. package/dist/tools/people.js +0 -177
  51. package/dist/tools/people.js.map +0 -1
  52. package/dist/tools/recordings.d.ts +0 -12
  53. package/dist/tools/recordings.d.ts.map +0 -1
  54. package/dist/tools/recordings.js +0 -145
  55. package/dist/tools/recordings.js.map +0 -1
  56. package/dist/tools/search.d.ts +0 -12
  57. package/dist/tools/search.d.ts.map +0 -1
  58. package/dist/tools/search.js +0 -56
  59. package/dist/tools/search.js.map +0 -1
  60. package/dist/tools/works.d.ts +0 -14
  61. package/dist/tools/works.d.ts.map +0 -1
  62. package/dist/tools/works.js +0 -248
  63. package/dist/tools/works.js.map +0 -1
  64. package/dist/utils/errors.d.ts +0 -14
  65. package/dist/utils/errors.d.ts.map +0 -1
  66. package/dist/utils/errors.js +0 -48
  67. package/dist/utils/errors.js.map +0 -1
  68. package/dist/utils/formatting.d.ts +0 -10
  69. package/dist/utils/formatting.d.ts.map +0 -1
  70. package/dist/utils/formatting.js +0 -16
  71. package/dist/utils/formatting.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withpica/mcp-server-directory",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "MCP Server for PICA Public Directory — enables AI assistants to search verified works and creators",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -8,6 +8,7 @@
8
8
  "pica-directory-mcp": "./dist/index.js"
9
9
  },
10
10
  "scripts": {
11
+ "prebuild": "tsx scripts/build-skills.ts",
11
12
  "build": "tsc",
12
13
  "dev": "tsx src/index.ts",
13
14
  "start": "node dist/index.js",
@@ -0,0 +1,192 @@
1
+ // Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
2
+
3
+ /**
4
+ * ADR-140 Phase 2b — generator for `skills.generated.ts` (directory MCP).
5
+ *
6
+ * Sister of mcp-server/scripts/build-skills.ts. Same shape, scoped to the
7
+ * directory MCP. Public/anonymous surface — every skill body must pass the
8
+ * IP-protection lint (scripts/lint-mcp-tools.ts Rule 14) before publishing.
9
+ */
10
+
11
+ import { readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const ROOT = join(__dirname, "..");
18
+ const SKILLS_ROOT = join(ROOT, "src/skills");
19
+ const OUTPUT_PATH = join(SKILLS_ROOT, "skills.generated.ts");
20
+
21
+ interface SkillFrontmatter {
22
+ name: string;
23
+ description: string;
24
+ triggers: string[];
25
+ audience: string;
26
+ tools_required: string[];
27
+ output: string;
28
+ }
29
+
30
+ interface ParsedSkill extends SkillFrontmatter {
31
+ body: string;
32
+ }
33
+
34
+ function parseFrontmatter(src: string, sourceFile: string): ParsedSkill {
35
+ let i = 0;
36
+ if (src.startsWith("<!--")) {
37
+ const end = src.indexOf("-->");
38
+ if (end === -1) {
39
+ throw new Error(`${sourceFile}: unterminated HTML comment header`);
40
+ }
41
+ i = end + 3;
42
+ while (i < src.length && /\s/.test(src[i])) i++;
43
+ }
44
+ if (src.slice(i, i + 3) !== "---") {
45
+ throw new Error(`${sourceFile}: missing --- frontmatter opener`);
46
+ }
47
+ i += 3;
48
+ while (i < src.length && src[i] !== "\n") i++;
49
+ i++;
50
+ const closeIdx = src.indexOf("\n---", i);
51
+ if (closeIdx === -1) {
52
+ throw new Error(`${sourceFile}: missing --- frontmatter closer`);
53
+ }
54
+ const frontmatterText = src.slice(i, closeIdx);
55
+ const bodyStart = closeIdx + 4;
56
+ const body = src.slice(bodyStart).replace(/^\n+/, "").trimEnd() + "\n";
57
+
58
+ const fm: Record<string, string | string[]> = {};
59
+ const lines = frontmatterText.split("\n");
60
+ let currentListKey: string | null = null;
61
+ for (const line of lines) {
62
+ if (line.trim() === "") {
63
+ currentListKey = null;
64
+ continue;
65
+ }
66
+ const listItemMatch = line.match(/^\s+-\s+(.+)$/);
67
+ if (listItemMatch && currentListKey) {
68
+ (fm[currentListKey] as string[]).push(listItemMatch[1].trim());
69
+ continue;
70
+ }
71
+ const kvMatch = line.match(/^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i);
72
+ if (!kvMatch) continue;
73
+ const key = kvMatch[1];
74
+ const value = kvMatch[2].trim();
75
+ if (value === "") {
76
+ fm[key] = [];
77
+ currentListKey = key;
78
+ } else {
79
+ fm[key] = value;
80
+ currentListKey = null;
81
+ }
82
+ }
83
+
84
+ const required = [
85
+ "name",
86
+ "description",
87
+ "triggers",
88
+ "audience",
89
+ "tools_required",
90
+ "output",
91
+ ];
92
+ for (const field of required) {
93
+ if (!(field in fm)) {
94
+ throw new Error(`${sourceFile}: missing required frontmatter field "${field}"`);
95
+ }
96
+ }
97
+ if (!Array.isArray(fm.triggers) || fm.triggers.length === 0) {
98
+ throw new Error(`${sourceFile}: triggers must be a non-empty list`);
99
+ }
100
+ if (!Array.isArray(fm.tools_required) || fm.tools_required.length === 0) {
101
+ throw new Error(`${sourceFile}: tools_required must be a non-empty list`);
102
+ }
103
+
104
+ return {
105
+ name: fm.name as string,
106
+ description: fm.description as string,
107
+ triggers: fm.triggers as string[],
108
+ audience: fm.audience as string,
109
+ tools_required: fm.tools_required as string[],
110
+ output: fm.output as string,
111
+ body,
112
+ };
113
+ }
114
+
115
+ function discoverSkills(): ParsedSkill[] {
116
+ if (!existsSync(SKILLS_ROOT)) {
117
+ return [];
118
+ }
119
+ const entries = readdirSync(SKILLS_ROOT, { withFileTypes: true });
120
+ const skills: ParsedSkill[] = [];
121
+ for (const entry of entries) {
122
+ if (!entry.isDirectory()) continue;
123
+ const skillFile = join(SKILLS_ROOT, entry.name, "SKILL.md");
124
+ if (!existsSync(skillFile)) continue;
125
+ const src = readFileSync(skillFile, "utf-8");
126
+ const parsed = parseFrontmatter(src, `src/skills/${entry.name}/SKILL.md`);
127
+ if (parsed.name !== entry.name) {
128
+ throw new Error(
129
+ `src/skills/${entry.name}/SKILL.md: frontmatter name "${parsed.name}" must match directory name "${entry.name}"`,
130
+ );
131
+ }
132
+ skills.push(parsed);
133
+ }
134
+ skills.sort((a, b) => a.name.localeCompare(b.name));
135
+ return skills;
136
+ }
137
+
138
+ function emit(skills: ParsedSkill[]): string {
139
+ const header = `// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
140
+
141
+ /**
142
+ * GENERATED FILE — DO NOT EDIT BY HAND.
143
+ *
144
+ * Regenerated by \`scripts/build-skills.ts\` from \`src/skills/<name>/SKILL.md\`.
145
+ * Runs in \`prebuild\`. See ADR-140 Phase 2b.
146
+ */
147
+
148
+ export interface Skill {
149
+ name: string;
150
+ description: string;
151
+ triggers: string[];
152
+ audience: string;
153
+ tools_required: string[];
154
+ output: string;
155
+ body: string;
156
+ }
157
+
158
+ `;
159
+ const lines: string[] = [header, "export const SKILLS: Record<string, Skill> = {"];
160
+ for (const skill of skills) {
161
+ lines.push(` ${JSON.stringify(skill.name)}: {`);
162
+ lines.push(` name: ${JSON.stringify(skill.name)},`);
163
+ lines.push(` description: ${JSON.stringify(skill.description)},`);
164
+ lines.push(` triggers: ${JSON.stringify(skill.triggers)},`);
165
+ lines.push(` audience: ${JSON.stringify(skill.audience)},`);
166
+ lines.push(` tools_required: ${JSON.stringify(skill.tools_required)},`);
167
+ lines.push(` output: ${JSON.stringify(skill.output)},`);
168
+ lines.push(` body: ${JSON.stringify(skill.body)},`);
169
+ lines.push(` },`);
170
+ }
171
+ lines.push("};");
172
+ lines.push("");
173
+ lines.push(
174
+ `export const SKILL_NAMES: ReadonlyArray<string> = ${JSON.stringify(skills.map((s) => s.name))};`,
175
+ );
176
+ lines.push("");
177
+ return lines.join("\n");
178
+ }
179
+
180
+ function main(): void {
181
+ const skills = discoverSkills();
182
+ const output = emit(skills);
183
+ writeFileSync(OUTPUT_PATH, output, "utf-8");
184
+ console.log(
185
+ `[build-skills/directory] Wrote ${skills.length} skill(s) to src/skills/skills.generated.ts`,
186
+ );
187
+ for (const skill of skills) {
188
+ console.log(` - ${skill.name} (${skill.body.length} bytes body)`);
189
+ }
190
+ }
191
+
192
+ main();
@@ -0,0 +1,63 @@
1
+ // Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
2
+
3
+ /**
4
+ * Directory SkillsRegistry test suite — ADR-140 Phase 2b
5
+ *
6
+ * Sister of mcp-server's skills-registry.test.ts. Scoped to the directory
7
+ * MCP's single Stage 1 skill (find-music-for-sync-brief) and its public-skill
8
+ * IP-exposure profile (lower bar — methodology body must be IP-safe).
9
+ */
10
+
11
+ import { describe, it, expect } from "@jest/globals";
12
+ import {
13
+ SkillsRegistry,
14
+ SkillNotFoundError,
15
+ } from "../../skills/index.js";
16
+
17
+ describe("Directory SkillsRegistry", () => {
18
+ const registry = new SkillsRegistry();
19
+
20
+ it("registers the Stage 1 directory skill", () => {
21
+ expect(registry.has("find-music-for-sync-brief")).toBe(true);
22
+ });
23
+
24
+ it("listSkills returns metadata-only summaries", () => {
25
+ for (const summary of registry.listSkills()) {
26
+ expect(summary).toHaveProperty("triggers");
27
+ expect(summary).not.toHaveProperty("body");
28
+ }
29
+ });
30
+
31
+ it("getSkill returns the methodology body", () => {
32
+ const skill = registry.getSkill("find-music-for-sync-brief");
33
+ expect(skill.body.length).toBeGreaterThan(100);
34
+ expect(skill.body).toContain("# find-music-for-sync-brief");
35
+ expect(skill.tools_required).toContain("directory_search_recordings");
36
+ });
37
+
38
+ it("getSkill throws SkillNotFoundError for unknown name", () => {
39
+ expect(() => registry.getSkill("not-a-real-skill")).toThrow(
40
+ SkillNotFoundError,
41
+ );
42
+ });
43
+
44
+ it("body contains no trade-secret path fragments (directory MCP is public)", () => {
45
+ const FORBIDDEN = [
46
+ "lib/services/chain",
47
+ "lib/services/enrichment",
48
+ "lib/services/pica-export",
49
+ "lib/services/global-identity",
50
+ "lib/services/identity-inheritance",
51
+ "lib/services/machine-payments",
52
+ "lib/services/credit-gating",
53
+ "lib/services/mlc-import",
54
+ "lib/integrations/",
55
+ ];
56
+ for (const summary of registry.listSkills()) {
57
+ const skill = registry.getSkill(summary.name);
58
+ for (const fragment of FORBIDDEN) {
59
+ expect(skill.body).not.toContain(fragment);
60
+ }
61
+ }
62
+ });
63
+ });
@@ -0,0 +1,74 @@
1
+ // Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
2
+
3
+ /**
4
+ * Directory skills tools test suite — ADR-140 Phase 2b
5
+ *
6
+ * Covers directory_skill_list + directory_skill_get on the public directory
7
+ * MCP. Same meta-tool shape as the creator MCP, but tools_required names
8
+ * follow the `directory_*` prefix convention.
9
+ */
10
+
11
+ import { describe, it, expect } from "@jest/globals";
12
+ import { DirectorySkillsTools } from "../../tools/skills.js";
13
+
14
+ describe("DirectorySkillsTools", () => {
15
+ const skillsTools = new DirectorySkillsTools();
16
+ const tools = skillsTools.getTools();
17
+
18
+ it("registers exactly 2 meta-tools", () => {
19
+ expect(tools.length).toBe(2);
20
+ expect(tools.map((t) => t.definition.name).sort()).toEqual([
21
+ "directory_skill_get",
22
+ "directory_skill_list",
23
+ ]);
24
+ });
25
+
26
+ it("both tools are tier:read", () => {
27
+ for (const tool of tools) {
28
+ expect(tool.definition.tier).toBe("read");
29
+ }
30
+ });
31
+
32
+ it("directory_skill_list returns the Stage 1 skill", async () => {
33
+ const list = tools.find(
34
+ (t) => t.definition.name === "directory_skill_list",
35
+ )!;
36
+ const result = await list.executor({});
37
+ expect(result.isError).toBeFalsy();
38
+ const summaries = result.structuredContent!.skills as Array<{
39
+ name: string;
40
+ }>;
41
+ expect(summaries.map((s) => s.name)).toContain(
42
+ "find-music-for-sync-brief",
43
+ );
44
+ });
45
+
46
+ it("directory_skill_get returns the methodology body", async () => {
47
+ const get = tools.find(
48
+ (t) => t.definition.name === "directory_skill_get",
49
+ )!;
50
+ const result = await get.executor({ name: "find-music-for-sync-brief" });
51
+ expect(result.isError).toBeFalsy();
52
+ const textBlock = result.content.find((c: any) => c.type === "text") as
53
+ | { type: "text"; text: string }
54
+ | undefined;
55
+ expect(textBlock?.text).toContain("# find-music-for-sync-brief");
56
+ expect(textBlock?.text).toContain("directory_search_recordings");
57
+ });
58
+
59
+ it("directory_skill_get returns isError for unknown skill", async () => {
60
+ const get = tools.find(
61
+ (t) => t.definition.name === "directory_skill_get",
62
+ )!;
63
+ const result = await get.executor({ name: "not-a-real-skill" });
64
+ expect(result.isError).toBe(true);
65
+ });
66
+
67
+ it("directory_skill_get returns isError when name is missing", async () => {
68
+ const get = tools.find(
69
+ (t) => t.definition.name === "directory_skill_get",
70
+ )!;
71
+ const result = await get.executor({});
72
+ expect(result.isError).toBe(true);
73
+ });
74
+ });
@@ -537,4 +537,21 @@ export const PUBLIC_ATLAS: AtlasEntry[] = [
537
537
  "title + writer | title + year | ISWC if known",
538
538
  },
539
539
  },
540
+ {
541
+ // ADR-140 Phase 2b — skill discovery meta-questions.
542
+ // Routes to directory_skill_list so an external LLM connecting to the
543
+ // directory MCP via Anthropic's MCP Directory or ChatGPT Apps catalogue
544
+ // can ask "what can this server do?" and get a list of named
545
+ // methodologies (find-music-for-sync-brief, etc.) rather than having
546
+ // to read the full 9-tool surface description-by-description.
547
+ question: "what skills do you have?",
548
+ synonyms: [
549
+ "what can you do?",
550
+ "show me your methodologies",
551
+ "how do I find music here?",
552
+ "how do I use this directory?",
553
+ "what workflows are available?",
554
+ ],
555
+ resolves_to: { kind: "tool", name: "directory_skill_list" },
556
+ },
540
557
  ];
package/src/server.ts CHANGED
@@ -16,13 +16,20 @@ import { ToolRegistry } from "./tools/index.js";
16
16
  import { PromptRegistry } from "./prompts/index.js";
17
17
  import { logError } from "./utils/errors.js";
18
18
  import { DIRECTORY_PRIMER } from "./resources/llms-primer.js";
19
+ import { SkillsRegistry } from "./skills/index.js";
19
20
 
20
21
  export class DirectoryMcpServer {
21
22
  private server: Server;
22
23
  private client: DirectoryClient;
23
24
  private toolRegistry: ToolRegistry;
24
25
  private promptRegistry: PromptRegistry;
26
+ private skillsRegistry: SkillsRegistry;
25
27
  private config: ServerConfig;
28
+ // Logged once after the MCP `initialize` handshake completes so each
29
+ // session prints a single client-attribution breadcrumb to stderr
30
+ // (Railway logs / Sentry breadcrumbs). Directory MCP has no DB audit
31
+ // pipeline today; this is provenance-only.
32
+ private clientAttributionLogged = false;
26
33
 
27
34
  constructor(config: ServerConfig) {
28
35
  this.config = config;
@@ -48,6 +55,7 @@ export class DirectoryMcpServer {
48
55
 
49
56
  this.toolRegistry = new ToolRegistry(this.client);
50
57
  this.promptRegistry = new PromptRegistry();
58
+ this.skillsRegistry = new SkillsRegistry();
51
59
  this.setupHandlers();
52
60
  }
53
61
 
@@ -60,17 +68,36 @@ export class DirectoryMcpServer {
60
68
  });
61
69
 
62
70
  this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
63
- return {
64
- resources: [
65
- {
66
- uri: "llms://primer",
67
- name: "PICA Domain Primer",
68
- description:
69
- "Plain-language description of PICA's domain model, entity relationships, and recommended agent workflows. Read this first.",
70
- mimeType: "text/markdown",
71
- },
72
- ],
73
- };
71
+ const resources: Array<{
72
+ uri: string;
73
+ name: string;
74
+ description: string;
75
+ mimeType: string;
76
+ }> = [
77
+ {
78
+ uri: "llms://primer",
79
+ name: "PICA Domain Primer",
80
+ description:
81
+ "Plain-language description of PICA's domain model, entity relationships, and recommended agent workflows. Read this first.",
82
+ mimeType: "text/markdown",
83
+ },
84
+ {
85
+ uri: "skill://withpica/index.json",
86
+ name: "PICA Directory Skills Index",
87
+ description:
88
+ "Enumeration of downloadable directory skill methodologies (SEP-2640 bridge). Each entry maps a kebab-case name to its skill:// URI, description, and trigger phrases.",
89
+ mimeType: "application/json",
90
+ },
91
+ ];
92
+ for (const summary of this.skillsRegistry.listSkills()) {
93
+ resources.push({
94
+ uri: `skill://withpica/${summary.name}/SKILL.md`,
95
+ name: `Skill — ${summary.name}`,
96
+ description: summary.description,
97
+ mimeType: "text/markdown",
98
+ });
99
+ }
100
+ return { resources };
74
101
  });
75
102
 
76
103
  this.server.setRequestHandler(
@@ -88,6 +115,9 @@ export class DirectoryMcpServer {
88
115
  ],
89
116
  };
90
117
  }
118
+ if (uri.startsWith("skill://withpica/")) {
119
+ return this.readSkillResource(uri);
120
+ }
91
121
  throw new Error(`Resource not found: ${uri}`);
92
122
  },
93
123
  );
@@ -104,6 +134,8 @@ export class DirectoryMcpServer {
104
134
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
105
135
  const { name, arguments: args } = request.params;
106
136
 
137
+ this.logClientAttributionOnce();
138
+
107
139
  if (this.config.debug) {
108
140
  console.error(`[Directory MCP] Executing tool: ${name}`, args);
109
141
  }
@@ -117,6 +149,86 @@ export class DirectoryMcpServer {
117
149
  });
118
150
  }
119
151
 
152
+ /**
153
+ * Emit a single stderr line per session naming the client that handshook
154
+ * with us — sourced from `clientInfo` on the MCP `initialize` request.
155
+ * Self-declared, NOT authenticated; treat as provenance metadata only.
156
+ *
157
+ * Directory MCP is unauthenticated and writes no audit rows, so this
158
+ * stderr breadcrumb is the only attribution surface today. When/if a
159
+ * directory audit pipeline lands, fold these values into McpAuditEntry
160
+ * (the wire shape already carries optional client_name + client_version).
161
+ */
162
+ private logClientAttributionOnce(): void {
163
+ if (this.clientAttributionLogged) return;
164
+ const info = this.server.getClientVersion?.();
165
+ if (!info) return;
166
+ this.clientAttributionLogged = true;
167
+ console.error(
168
+ `[Directory MCP] client: ${info.name ?? "unknown"} v${info.version ?? "unknown"}`,
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Resolve a skill:// URI. ADR-140 Phase 2b — SEP-2640 bridge pattern.
174
+ * Two URI shapes:
175
+ * - skill://withpica/index.json
176
+ * - skill://withpica/<name>/SKILL.md
177
+ */
178
+ private readSkillResource(uri: string): {
179
+ contents: Array<{ uri: string; mimeType: string; text: string }>;
180
+ } {
181
+ if (uri === "skill://withpica/index.json") {
182
+ const index = this.skillsRegistry.listSkills().map((s) => ({
183
+ name: s.name,
184
+ uri: `skill://withpica/${s.name}/SKILL.md`,
185
+ description: s.description,
186
+ triggers: s.triggers,
187
+ audience: s.audience,
188
+ }));
189
+ return {
190
+ contents: [
191
+ {
192
+ uri,
193
+ mimeType: "application/json",
194
+ text: JSON.stringify({ skills: index }, null, 2),
195
+ },
196
+ ],
197
+ };
198
+ }
199
+ const match = uri.match(/^skill:\/\/withpica\/([a-z0-9-]+)\/SKILL\.md$/);
200
+ if (!match) {
201
+ throw new Error(`Malformed skill URI: ${uri}`);
202
+ }
203
+ const name = match[1];
204
+ if (!this.skillsRegistry.has(name)) {
205
+ throw new Error(`Skill not found: ${name}`);
206
+ }
207
+ const skill = this.skillsRegistry.getSkill(name);
208
+ const frontmatter = [
209
+ "---",
210
+ `name: ${skill.name}`,
211
+ `description: ${skill.description}`,
212
+ "triggers:",
213
+ ...skill.triggers.map((t) => ` - ${t}`),
214
+ `audience: ${skill.audience}`,
215
+ "tools_required:",
216
+ ...skill.tools_required.map((t) => ` - ${t}`),
217
+ `output: ${skill.output}`,
218
+ "---",
219
+ "",
220
+ ].join("\n");
221
+ return {
222
+ contents: [
223
+ {
224
+ uri,
225
+ mimeType: "text/markdown",
226
+ text: frontmatter + skill.body,
227
+ },
228
+ ],
229
+ };
230
+ }
231
+
120
232
  async start(): Promise<void> {
121
233
  const transport = new StdioServerTransport();
122
234
  await this.server.connect(transport);
@@ -0,0 +1,89 @@
1
+ <!-- Copyright (c) 2024-2026 Withpica Ltd. All rights reserved. -->
2
+ ---
3
+ name: find-music-for-sync-brief
4
+ description: Find music in the PICA public directory for a sync brief — translate a mood/vibe/scene description into audio search parameters, run the search, and return a rights-aware shortlist with credit details for licensing.
5
+ triggers:
6
+ - find music for a sync brief
7
+ - find me a track for a film scene
8
+ - music for this brief
9
+ - find an uplifting indie folk track
10
+ - I need music that sounds like
11
+ audience: sync supervisor / music library / ad agency / film & TV music team
12
+ tools_required:
13
+ - directory_search_recordings
14
+ - directory_lookup_work
15
+ - directory_lookup_person
16
+ output: A rights-aware shortlist — each result with title, artist, audio characteristics (BPM, key, energy), credits summary for licensing contact, and any flags (unattested credits, low registration score, missing ISRC).
17
+ ---
18
+
19
+ # find-music-for-sync-brief
20
+
21
+ Help the user find music in PICA's public directory for a sync brief. Translate their description (mood, vibe, scene, era, vocal style) into search parameters, run the search, and return a shortlist that includes the information they need to license — not just the track.
22
+
23
+ This is a public skill — any AI agent connected to the directory MCP can use it. The directory only contains works from organisations that have opted in.
24
+
25
+ ## Step 1 — Translate the brief
26
+
27
+ Most sync briefs are described in natural language. Translate before searching:
28
+
29
+ | User says | Search parameters |
30
+ |---|---|
31
+ | upbeat / uplifting / driving | `min_energy: 0.6`, `min_danceability: 0.5` |
32
+ | dark / moody / brooding | `max_energy: 0.4`, `key_mode: minor` |
33
+ | chill / ambient / background | `max_energy: 0.4`, `max_bpm: 100` |
34
+ | epic / cinematic | `min_energy: 0.7`, often longer durations |
35
+ | specific BPM ("around 120bpm") | `min_bpm: 115`, `max_bpm: 125` |
36
+ | acoustic / sparse | typically lower `danceability` and `energy` |
37
+ | vocal / instrumental | filter on `has_vocals` if available |
38
+
39
+ If the user gives an artist or song reference ("something like X"), look up that reference first via `directory_lookup_work` or `directory_search_recordings` by title — then mimic its audio profile in the new search.
40
+
41
+ ## Step 2 — Search
42
+
43
+ Call `directory_search_recordings` with the parameters from step 1. Default limit is fine for an initial scan.
44
+
45
+ If the brief is vague, do two searches with slightly different parameters and compare. If too many results, narrow on BPM or key. If too few, widen the BPM range first, then drop key_mode.
46
+
47
+ ## Step 3 — Lookup full details
48
+
49
+ For the top 5-10 promising results, call `directory_lookup_work` to get:
50
+
51
+ - **Who wrote it** — credits with IPI numbers (essential for the licensing contact path)
52
+ - **Whether credits are attested** — an unattested credit means the ownership claim hasn't been verified; flag this
53
+ - **DSP links** (Spotify, Apple Music) — the user wants to listen before shortlisting
54
+ - **Registration score** — higher score = cleaner rights chain = easier to license
55
+
56
+ For high-value matches where the user wants to know more about the creator, call `directory_lookup_person` with the IPI to see the writer's broader catalog and collaborator network.
57
+
58
+ ## Step 4 — Present the shortlist
59
+
60
+ Present each shortlisted track with:
61
+
62
+ - **Title, artist** — what the user listens for
63
+ - **Audio profile** — BPM, key, energy (one line, the supervisor's quick filter)
64
+ - **Credits summary** — writers with IPI, publisher if known (this is the licensing path)
65
+ - **DSP links** — so the user can listen
66
+ - **Flags** — unattested credits, low registration score, missing ISRC, multiple writers (= multi-party clearance needed)
67
+
68
+ If a track has all green flags (attested credits, high registration score, ISRC present, single or low-count writers), call it out as "clean to clear."
69
+
70
+ ## Step 5 — If no results
71
+
72
+ If the search returns nothing, walk the user back through the brief:
73
+ - Widen the BPM range first (most supervisors don't actually mind ±10 BPM)
74
+ - Drop the key filter (key matching matters less than mood for most placements)
75
+ - Drop the energy bounds and search by genre/mood text only
76
+ - Suggest the user expand to adjacent moods ("uplifting" → also try "warm" or "hopeful")
77
+
78
+ Be honest if the directory is small for a specific style — PICA's directory grows with org opt-ins; some niches are sparse.
79
+
80
+ ## What not to do
81
+
82
+ - Don't claim to license tracks — this skill returns a shortlist, not a license. The user contacts the rights holder via the credits summary.
83
+ - Don't speculate about pricing — sync fees are negotiated per-placement
84
+ - Don't return results from outside the directory (no general web search, no Spotify metadata directly) — the directory is the source of truth for this skill
85
+ - Don't return tracks with no attested credits without flagging them as "rights chain unverified"
86
+
87
+ ## Follow-on skills
88
+
89
+ - `due-diligence-on-creator` (Stage 2 candidate) — for deeper research on a specific writer before reaching out