@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.
- package/package.json +2 -1
- package/scripts/build-skills.ts +192 -0
- package/src/__tests__/skills/skills-registry.test.ts +63 -0
- package/src/__tests__/skills/skills-tools.test.ts +74 -0
- package/src/prompts/public-question-atlas.ts +17 -0
- package/src/server.ts +123 -11
- package/src/skills/find-music-for-sync-brief/SKILL.md +89 -0
- package/src/skills/index.ts +63 -0
- package/src/skills/skills.generated.ts +33 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/skills.ts +146 -0
- package/dist/client.d.ts +0 -12
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -93
- package/dist/client.js.map +0 -1
- package/dist/config.d.ts +0 -9
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -15
- package/dist/config.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -28
- package/dist/index.js.map +0 -1
- package/dist/prompts/index.d.ts +0 -32
- package/dist/prompts/index.d.ts.map +0 -1
- package/dist/prompts/index.js +0 -165
- package/dist/prompts/index.js.map +0 -1
- package/dist/prompts/public-question-atlas.d.ts +0 -121
- package/dist/prompts/public-question-atlas.d.ts.map +0 -1
- package/dist/prompts/public-question-atlas.js +0 -404
- package/dist/prompts/public-question-atlas.js.map +0 -1
- package/dist/resources/llms-primer.d.ts +0 -2
- package/dist/resources/llms-primer.d.ts.map +0 -1
- package/dist/resources/llms-primer.js +0 -35
- package/dist/resources/llms-primer.js.map +0 -1
- package/dist/server.d.ts +0 -13
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -104
- package/dist/server.js.map +0 -1
- package/dist/tools/chain.d.ts +0 -12
- package/dist/tools/chain.d.ts.map +0 -1
- package/dist/tools/chain.js +0 -109
- package/dist/tools/chain.js.map +0 -1
- package/dist/tools/index.d.ts +0 -35
- package/dist/tools/index.d.ts.map +0 -1
- package/dist/tools/index.js +0 -45
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/people.d.ts +0 -13
- package/dist/tools/people.d.ts.map +0 -1
- package/dist/tools/people.js +0 -177
- package/dist/tools/people.js.map +0 -1
- package/dist/tools/recordings.d.ts +0 -12
- package/dist/tools/recordings.d.ts.map +0 -1
- package/dist/tools/recordings.js +0 -145
- package/dist/tools/recordings.js.map +0 -1
- package/dist/tools/search.d.ts +0 -12
- package/dist/tools/search.d.ts.map +0 -1
- package/dist/tools/search.js +0 -56
- package/dist/tools/search.js.map +0 -1
- package/dist/tools/works.d.ts +0 -14
- package/dist/tools/works.d.ts.map +0 -1
- package/dist/tools/works.js +0 -248
- package/dist/tools/works.js.map +0 -1
- package/dist/utils/errors.d.ts +0 -14
- package/dist/utils/errors.d.ts.map +0 -1
- package/dist/utils/errors.js +0 -48
- package/dist/utils/errors.js.map +0 -1
- package/dist/utils/formatting.d.ts +0 -10
- package/dist/utils/formatting.d.ts.map +0 -1
- package/dist/utils/formatting.js +0 -16
- 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.
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|