@withpica/mcp-server-directory 1.4.1 → 1.5.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/CHANGELOG.md +53 -1
- package/README.md +63 -0
- package/dist/client.d.ts +14 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +29 -2
- package/dist/client.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/changelog.generated.d.ts +2 -2
- package/dist/lib/changelog.generated.d.ts.map +1 -1
- package/dist/lib/changelog.generated.js +3 -3
- package/dist/lib/changelog.generated.js.map +1 -1
- package/dist/lib/changelog.js +1 -1
- package/dist/prompts/index.js +2 -2
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/public-question-atlas.d.ts.map +1 -1
- package/dist/prompts/public-question-atlas.js +15 -1
- package/dist/prompts/public-question-atlas.js.map +1 -1
- package/dist/resources/llms-primer.d.ts +1 -1
- package/dist/resources/llms-primer.d.ts.map +1 -1
- package/dist/resources/llms-primer.js +7 -3
- package/dist/resources/llms-primer.js.map +1 -1
- package/dist/server.js +1 -1
- package/dist/skills/index.js +1 -1
- package/dist/skills/skills.generated.js +2 -2
- package/dist/skills/skills.generated.js.map +1 -1
- package/dist/tools/chain.js +1 -1
- package/dist/tools/enquiries.d.ts +19 -0
- package/dist/tools/enquiries.d.ts.map +1 -0
- package/dist/tools/enquiries.js +190 -0
- package/dist/tools/enquiries.js.map +1 -0
- package/dist/tools/index.d.ts +4 -3
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/people.js +1 -1
- package/dist/tools/recordings.js +1 -1
- package/dist/tools/release-notes.d.ts.map +1 -1
- package/dist/tools/release-notes.js +3 -2
- package/dist/tools/release-notes.js.map +1 -1
- package/dist/tools/search.js +1 -1
- package/dist/tools/skills.d.ts.map +1 -1
- package/dist/tools/skills.js +5 -3
- package/dist/tools/skills.js.map +1 -1
- package/dist/tools/works.d.ts.map +1 -1
- package/dist/tools/works.js +18 -2
- package/dist/tools/works.js.map +1 -1
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +57 -4
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/formatting.d.ts.map +1 -1
- package/dist/utils/formatting.js +9 -3
- package/dist/utils/formatting.js.map +1 -1
- package/jest.config.js +4 -1
- package/package.json +9 -5
- package/scripts/build-changelog.ts +2 -2
- package/scripts/build-skills.ts +9 -4
- package/src/__tests__/prompts/index.test.ts +1 -1
- package/src/__tests__/prompts/prompt-eval-harness.test.ts +4 -3
- package/src/__tests__/skills/skills-registry.test.ts +2 -5
- package/src/__tests__/skills/skills-tools.test.ts +5 -13
- package/src/__tests__/tools/chain.test.ts +1 -1
- package/src/__tests__/tools/composability-chains.test.ts +15 -26
- package/src/__tests__/tools/enquiries.test.ts +135 -0
- package/src/__tests__/tools/people.test.ts +1 -1
- package/src/__tests__/tools/search.test.ts +1 -1
- package/src/__tests__/tools/tool-count-parity.test.ts +63 -0
- package/src/__tests__/tools/works.test.ts +4 -1
- package/src/__tests__/utils/errors.test.ts +68 -0
- package/src/client.ts +38 -2
- package/src/config.ts +1 -1
- package/src/index.ts +1 -2
- package/src/lib/changelog.ts +1 -1
- package/src/prompts/index.ts +2 -2
- package/src/prompts/public-question-atlas.ts +16 -1
- package/src/resources/llms-primer.ts +7 -3
- package/src/server.ts +1 -1
- package/src/skills/find-music-for-sync-brief/SKILL.md +2 -2
- package/src/skills/index.ts +1 -1
- package/src/skills/skills.generated.ts +2 -2
- package/src/tools/__tests__/release-notes.test.ts +1 -1
- package/src/tools/chain.ts +1 -1
- package/src/tools/enquiries.ts +210 -0
- package/src/tools/index.ts +7 -4
- package/src/tools/people.ts +1 -1
- package/src/tools/recordings.ts +1 -1
- package/src/tools/release-notes.ts +3 -2
- package/src/tools/search.ts +1 -1
- package/src/tools/skills.ts +5 -3
- package/src/tools/works.ts +20 -2
- package/src/utils/errors.ts +72 -4
- package/src/utils/formatting.ts +9 -3
- package/tsconfig.build.json +10 -0
- package/dist/tools/__tests__/release-notes.test.d.ts +0 -2
- package/dist/tools/__tests__/release-notes.test.d.ts.map +0 -1
- package/dist/tools/__tests__/release-notes.test.js +0 -49
- package/dist/tools/__tests__/release-notes.test.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withpica/mcp-server-directory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "MCP Server for the withPICA Public Directory — enables AI assistants to search verified works and creators",
|
|
5
|
+
"homepage": "https://withpica.com/connect",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"email": "support@withpica.com"
|
|
8
|
+
},
|
|
5
9
|
"type": "module",
|
|
6
10
|
"main": "dist/index.js",
|
|
7
11
|
"bin": {
|
|
8
|
-
"pica-directory-mcp": "
|
|
12
|
+
"pica-directory-mcp": "dist/index.js"
|
|
9
13
|
},
|
|
10
14
|
"scripts": {
|
|
11
15
|
"prebuild": "tsx scripts/build-changelog.ts && tsx scripts/build-skills.ts",
|
|
12
|
-
"build": "tsc",
|
|
16
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
13
17
|
"dev": "tsx src/index.ts",
|
|
14
18
|
"start": "node dist/index.js",
|
|
15
19
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
@@ -25,11 +29,11 @@
|
|
|
25
29
|
},
|
|
26
30
|
"devDependencies": {
|
|
27
31
|
"@types/jest": "^29.5.14",
|
|
28
|
-
"@types/node": "^20.
|
|
32
|
+
"@types/node": "^20.19.43",
|
|
29
33
|
"jest": "^29.7.0",
|
|
30
34
|
"ts-jest": "^29.4.5",
|
|
31
35
|
"tsx": "^4.7.0",
|
|
32
|
-
"typescript": "^
|
|
36
|
+
"typescript": "^6.0.3"
|
|
33
37
|
},
|
|
34
38
|
"engines": {
|
|
35
39
|
"node": ">=18.0.0"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
import { readFileSync, writeFileSync } from "fs";
|
|
4
4
|
import { resolve, dirname } from "path";
|
|
@@ -15,7 +15,7 @@ const escaped = JSON.stringify(md);
|
|
|
15
15
|
|
|
16
16
|
writeFileSync(
|
|
17
17
|
outPath,
|
|
18
|
-
`// Copyright (c)
|
|
18
|
+
`// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.\n` +
|
|
19
19
|
`// Generated by scripts/build-changelog.ts — do not edit.\n\n` +
|
|
20
20
|
`export const CHANGELOG_MD = ${escaped};\n` +
|
|
21
21
|
`export const PACKAGE_NAME = ${JSON.stringify(pkg.name)};\n` +
|
package/scripts/build-skills.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* ADR-140 Phase 2b — generator for `skills.generated.ts` (directory MCP).
|
|
@@ -91,7 +91,9 @@ function parseFrontmatter(src: string, sourceFile: string): ParsedSkill {
|
|
|
91
91
|
];
|
|
92
92
|
for (const field of required) {
|
|
93
93
|
if (!(field in fm)) {
|
|
94
|
-
throw new Error(
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${sourceFile}: missing required frontmatter field "${field}"`,
|
|
96
|
+
);
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
if (!Array.isArray(fm.triggers) || fm.triggers.length === 0) {
|
|
@@ -136,7 +138,7 @@ function discoverSkills(): ParsedSkill[] {
|
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
function emit(skills: ParsedSkill[]): string {
|
|
139
|
-
const header = `// Copyright (c)
|
|
141
|
+
const header = `// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
140
142
|
|
|
141
143
|
/**
|
|
142
144
|
* GENERATED FILE — DO NOT EDIT BY HAND.
|
|
@@ -156,7 +158,10 @@ export interface Skill {
|
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
`;
|
|
159
|
-
const lines: string[] = [
|
|
161
|
+
const lines: string[] = [
|
|
162
|
+
header,
|
|
163
|
+
"export const SKILLS: Record<string, Skill> = {",
|
|
164
|
+
];
|
|
160
165
|
for (const skill of skills) {
|
|
161
166
|
lines.push(` ${JSON.stringify(skill.name)}: {`);
|
|
162
167
|
lines.push(` name: ${JSON.stringify(skill.name)},`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Directory Prompt Eval Harness — ADR-140 Phase 3
|
|
@@ -273,10 +273,11 @@ describe("Prompt Eval Harness — Directory MCP", () => {
|
|
|
273
273
|
expect(noTools).toEqual([]);
|
|
274
274
|
});
|
|
275
275
|
|
|
276
|
-
it("directory-autopilot is read-only
|
|
276
|
+
it("directory-autopilot says the catalogue is read-only and the one write needs the user's say-so", async () => {
|
|
277
277
|
const result = await registry.getPrompt("directory-autopilot");
|
|
278
278
|
const text = result.messages[0].content.text;
|
|
279
|
-
expect(text).toMatch(/read-only/i);
|
|
279
|
+
expect(text).toMatch(/catalogue is read-only/i);
|
|
280
|
+
expect(text).toMatch(/only if you ask/i);
|
|
280
281
|
});
|
|
281
282
|
});
|
|
282
283
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Directory SkillsRegistry test suite — ADR-140 Phase 2b
|
|
@@ -9,10 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { describe, it, expect } from "@jest/globals";
|
|
12
|
-
import {
|
|
13
|
-
SkillsRegistry,
|
|
14
|
-
SkillNotFoundError,
|
|
15
|
-
} from "../../skills/index.js";
|
|
12
|
+
import { SkillsRegistry, SkillNotFoundError } from "../../skills/index.js";
|
|
16
13
|
|
|
17
14
|
describe("Directory SkillsRegistry", () => {
|
|
18
15
|
const registry = new SkillsRegistry();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Directory skills tools test suite — ADR-140 Phase 2b
|
|
@@ -38,15 +38,11 @@ describe("DirectorySkillsTools", () => {
|
|
|
38
38
|
const summaries = result.structuredContent!.skills as Array<{
|
|
39
39
|
name: string;
|
|
40
40
|
}>;
|
|
41
|
-
expect(summaries.map((s) => s.name)).toContain(
|
|
42
|
-
"find-music-for-sync-brief",
|
|
43
|
-
);
|
|
41
|
+
expect(summaries.map((s) => s.name)).toContain("find-music-for-sync-brief");
|
|
44
42
|
});
|
|
45
43
|
|
|
46
44
|
it("directory_skill_get returns the methodology body", async () => {
|
|
47
|
-
const get = tools.find(
|
|
48
|
-
(t) => t.definition.name === "directory_skill_get",
|
|
49
|
-
)!;
|
|
45
|
+
const get = tools.find((t) => t.definition.name === "directory_skill_get")!;
|
|
50
46
|
const result = await get.executor({ name: "find-music-for-sync-brief" });
|
|
51
47
|
expect(result.isError).toBeFalsy();
|
|
52
48
|
const textBlock = result.content.find((c: any) => c.type === "text") as
|
|
@@ -57,17 +53,13 @@ describe("DirectorySkillsTools", () => {
|
|
|
57
53
|
});
|
|
58
54
|
|
|
59
55
|
it("directory_skill_get returns isError for unknown skill", async () => {
|
|
60
|
-
const get = tools.find(
|
|
61
|
-
(t) => t.definition.name === "directory_skill_get",
|
|
62
|
-
)!;
|
|
56
|
+
const get = tools.find((t) => t.definition.name === "directory_skill_get")!;
|
|
63
57
|
const result = await get.executor({ name: "not-a-real-skill" });
|
|
64
58
|
expect(result.isError).toBe(true);
|
|
65
59
|
});
|
|
66
60
|
|
|
67
61
|
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
|
-
)!;
|
|
62
|
+
const get = tools.find((t) => t.definition.name === "directory_skill_get")!;
|
|
71
63
|
const result = await get.executor({});
|
|
72
64
|
expect(result.isError).toBe(true);
|
|
73
65
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Directory MCP — Composability Chain Tests
|
|
@@ -6,33 +6,22 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect, beforeAll } from "@jest/globals";
|
|
9
|
-
import {
|
|
10
|
-
import { DirectoryPeopleTools } from "../../tools/people";
|
|
11
|
-
import { DirectorySearchTools } from "../../tools/search";
|
|
12
|
-
import { DirectoryRecordingsTools } from "../../tools/recordings";
|
|
13
|
-
import { DirectoryChainTools } from "../../tools/chain";
|
|
9
|
+
import { ToolRegistry } from "../../tools/index";
|
|
14
10
|
|
|
15
11
|
const nullClient = null as any;
|
|
16
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 2026-08-24 — derived from the REAL registry, not a local class list. The
|
|
15
|
+
* previous fixed five-class list here missed DirectorySkillsTools and
|
|
16
|
+
* DirectoryReleaseNotesTools, so "all 8 tools are registered" passed while
|
|
17
|
+
* the server advertised 11 — a fixed list cannot see the class nobody
|
|
18
|
+
* listed (same failure family as the shows-trust-floor guard).
|
|
19
|
+
*/
|
|
17
20
|
function getAllTools() {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
new DirectoryRecordingsTools(nullClient),
|
|
23
|
-
new DirectoryChainTools(nullClient),
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
const tools: Array<{ name: string; description: string }> = [];
|
|
27
|
-
for (const cls of classes) {
|
|
28
|
-
for (const tool of cls.getTools()) {
|
|
29
|
-
tools.push({
|
|
30
|
-
name: tool.definition.name,
|
|
31
|
-
description: tool.definition.description,
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return tools;
|
|
21
|
+
return new ToolRegistry(nullClient).listTools().map((definition) => ({
|
|
22
|
+
name: definition.name,
|
|
23
|
+
description: definition.description,
|
|
24
|
+
}));
|
|
36
25
|
}
|
|
37
26
|
|
|
38
27
|
function parseChains(description: string): string[] {
|
|
@@ -50,8 +39,8 @@ describe("Directory Composability Chains", () => {
|
|
|
50
39
|
allNames = new Set(allTools.map((t) => t.name));
|
|
51
40
|
});
|
|
52
41
|
|
|
53
|
-
it("all
|
|
54
|
-
expect(allTools).toHaveLength(
|
|
42
|
+
it("all 12 tools are registered (parity with lib/constants/mcp-surface.ts is enforced by tool-count-parity.test.ts)", () => {
|
|
43
|
+
expect(allTools).toHaveLength(12);
|
|
55
44
|
});
|
|
56
45
|
|
|
57
46
|
it("all tools have composability chains", () => {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* directory_send_licensing_enquiry — agents browsing the public directory had
|
|
5
|
+
* no way to reach an owner; the sync skill told them to "contact the rights
|
|
6
|
+
* holder via the credits summary" (brief 2026-09-25).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
|
|
10
|
+
import { DirectoryEnquiryTools } from "../../tools/enquiries";
|
|
11
|
+
import { DirectoryClient } from "../../client";
|
|
12
|
+
|
|
13
|
+
const WORK = "11111111-2222-4333-8444-555555555555";
|
|
14
|
+
|
|
15
|
+
describe("directory_send_licensing_enquiry", () => {
|
|
16
|
+
let client: jest.Mocked<DirectoryClient>;
|
|
17
|
+
let tool: ReturnType<DirectoryEnquiryTools["getTools"]>[number];
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
client = {
|
|
21
|
+
sendLicensingEnquiry: jest.fn(),
|
|
22
|
+
workPageUrl: jest.fn(
|
|
23
|
+
(id: string) => `https://withpica.com/directory/works/${id}`,
|
|
24
|
+
),
|
|
25
|
+
} as any;
|
|
26
|
+
tool = new DirectoryEnquiryTools(client).getTools()[0];
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const args = {
|
|
30
|
+
work_id: WORK,
|
|
31
|
+
contact_name: "Jane Doe",
|
|
32
|
+
contact_email: "jane@films.test",
|
|
33
|
+
project_type: "film",
|
|
34
|
+
project_description: "opening titles",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
it("is the one write-tier directory tool and requires the user's own details", () => {
|
|
38
|
+
expect(tool.definition.tier).toBe("write");
|
|
39
|
+
expect(tool.definition.inputSchema.required).toEqual([
|
|
40
|
+
"work_id",
|
|
41
|
+
"contact_name",
|
|
42
|
+
"contact_email",
|
|
43
|
+
"project_type",
|
|
44
|
+
"project_description",
|
|
45
|
+
]);
|
|
46
|
+
expect(tool.definition.inputSchema.required).not.toContain("budget_amount");
|
|
47
|
+
expect(tool.definition.description).toMatch(/ONLY call after the user/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("maps to the public enquiry body, says 'not specified' for what the user didn't give, and returns the work page", async () => {
|
|
51
|
+
client.sendLicensingEnquiry.mockResolvedValue({
|
|
52
|
+
status: 200,
|
|
53
|
+
json: { success: true, data: { id: "enq-1", status: "pending" } },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = await tool.executor(args);
|
|
57
|
+
|
|
58
|
+
expect(client.sendLicensingEnquiry).toHaveBeenCalledWith(
|
|
59
|
+
expect.objectContaining({
|
|
60
|
+
workId: WORK,
|
|
61
|
+
contactName: "Jane Doe",
|
|
62
|
+
contactEmail: "jane@films.test",
|
|
63
|
+
territory: "not specified",
|
|
64
|
+
duration: "not specified",
|
|
65
|
+
proposedBudgetAmount: null,
|
|
66
|
+
proposedBudgetCurrency: "GBP",
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
expect(result.isError).toBeUndefined();
|
|
70
|
+
expect(result.structuredContent).toEqual({
|
|
71
|
+
sent: true,
|
|
72
|
+
enquiry_id: "enq-1",
|
|
73
|
+
status: "pending",
|
|
74
|
+
work_page_url: `https://withpica.com/directory/works/${WORK}`,
|
|
75
|
+
});
|
|
76
|
+
expect(result.content[0].text).toContain("nothing has been agreed");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it.each([
|
|
80
|
+
[403, /not taking licensing enquiries/],
|
|
81
|
+
[404, /not listed in the public directory/],
|
|
82
|
+
[429, /Too many enquiries/],
|
|
83
|
+
])("reports HTTP %i as not sent, with the reason", async (status, text) => {
|
|
84
|
+
client.sendLicensingEnquiry.mockResolvedValue({
|
|
85
|
+
status,
|
|
86
|
+
json: { success: false, error: { code: "X", message: "x" } },
|
|
87
|
+
});
|
|
88
|
+
const result = await tool.executor(args);
|
|
89
|
+
expect(result.isError).toBe(true);
|
|
90
|
+
expect(result.structuredContent).toMatchObject({
|
|
91
|
+
sent: false,
|
|
92
|
+
http_status: status,
|
|
93
|
+
});
|
|
94
|
+
expect(result.content[0].text).toMatch(text);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Review finding: the route stores, THEN emails. A 5xx or a lost answer can
|
|
98
|
+
// follow a stored enquiry, so "not sent" would invite a duplicate.
|
|
99
|
+
it.each([500, 502])(
|
|
100
|
+
"reports HTTP %i as an unknown outcome, not 'not sent'",
|
|
101
|
+
async (status) => {
|
|
102
|
+
client.sendLicensingEnquiry.mockResolvedValue({ status, json: null });
|
|
103
|
+
const result = await tool.executor(args);
|
|
104
|
+
expect(result.structuredContent).toMatchObject({
|
|
105
|
+
sent: null,
|
|
106
|
+
outcome: "unknown",
|
|
107
|
+
});
|
|
108
|
+
expect(result.content[0].text).toMatch(/Do not resend/);
|
|
109
|
+
expect(result.content[0].text).not.toMatch(/^Not sent/);
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
it("reports a timeout as an unknown outcome", async () => {
|
|
114
|
+
client.sendLicensingEnquiry.mockRejectedValue(
|
|
115
|
+
new Error("Request timed out after 15000ms: POST x"),
|
|
116
|
+
);
|
|
117
|
+
const result = await tool.executor(args);
|
|
118
|
+
expect(result.structuredContent).toMatchObject({ outcome: "unknown" });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("passes a 400's own message through", async () => {
|
|
122
|
+
client.sendLicensingEnquiry.mockResolvedValue({
|
|
123
|
+
status: 400,
|
|
124
|
+
json: {
|
|
125
|
+
success: false,
|
|
126
|
+
error: {
|
|
127
|
+
code: "ENQUIRY_EMAIL_INVALID",
|
|
128
|
+
message: "Invalid email format",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const result = await tool.executor(args);
|
|
133
|
+
expect(result.content[0].text).toContain("Invalid email format");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Directory tool-count parity tripwire — sister of
|
|
5
|
+
* mcp-server/src/__tests__/tools/tool-count-parity.test.ts.
|
|
6
|
+
*
|
|
7
|
+
* MCP_DIRECTORY_TOOL_COUNT in `lib/constants/mcp-surface.ts` is published
|
|
8
|
+
* live at /.well-known/mcp.json and /.well-known/mcp/server-card.json. It
|
|
9
|
+
* was documented as "small, hand-verified" and drifted exactly as
|
|
10
|
+
* hand-verified numbers do: it read 9 while this registry served 11
|
|
11
|
+
* (directory_search_recordings and directory_release_notes landed without a
|
|
12
|
+
* bump). This test builds the REAL registry and fails when the constant
|
|
13
|
+
* drifts — update the constant in the same PR as any directory tool
|
|
14
|
+
* addition, removal, or retirement.
|
|
15
|
+
*
|
|
16
|
+
* The constant is read from the file as text (not imported) — the app and
|
|
17
|
+
* this package are deliberately isolated builds; a source-text read keeps
|
|
18
|
+
* the tripwire without coupling them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from "@jest/globals";
|
|
22
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
23
|
+
import { resolve } from "node:path";
|
|
24
|
+
import { ToolRegistry } from "../../tools/index";
|
|
25
|
+
|
|
26
|
+
function readDeclaredCount(): { count: number; source: string } {
|
|
27
|
+
// jest runs with cwd = mcp-server-directory (package script) but tolerate
|
|
28
|
+
// a repo-root cwd too.
|
|
29
|
+
const candidates = [
|
|
30
|
+
resolve(process.cwd(), "..", "lib", "constants", "mcp-surface.ts"),
|
|
31
|
+
resolve(process.cwd(), "lib", "constants", "mcp-surface.ts"),
|
|
32
|
+
];
|
|
33
|
+
const source = candidates.find((p) => existsSync(p));
|
|
34
|
+
if (!source) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`lib/constants/mcp-surface.ts not found from cwd=${process.cwd()} — the app-side directory tool-count constant is missing`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const text = readFileSync(source, "utf8");
|
|
40
|
+
const match = text.match(/MCP_DIRECTORY_TOOL_COUNT\s*=\s*(\d+)/);
|
|
41
|
+
if (!match) {
|
|
42
|
+
throw new Error(`MCP_DIRECTORY_TOOL_COUNT not declared in ${source}`);
|
|
43
|
+
}
|
|
44
|
+
return { count: Number(match[1]), source };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("directory tool-count parity — app constant vs real registry", () => {
|
|
48
|
+
it("MCP_DIRECTORY_TOOL_COUNT equals the registry's advertised surface", () => {
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
const registry = new ToolRegistry(null as any);
|
|
51
|
+
const advertised = registry.listTools().length;
|
|
52
|
+
const declared = readDeclaredCount();
|
|
53
|
+
|
|
54
|
+
if (advertised !== declared.count) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Directory tool count drifted: registry advertises ${advertised} tools, ` +
|
|
57
|
+
`but MCP_DIRECTORY_TOOL_COUNT in ${declared.source} declares ${declared.count}. ` +
|
|
58
|
+
`Update the constant in the same PR as the tool change.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
expect(advertised).toBe(declared.count);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
jest,
|
|
@@ -18,6 +18,9 @@ describe("DirectoryWorksTools", () => {
|
|
|
18
18
|
beforeEach(() => {
|
|
19
19
|
mockClient = {
|
|
20
20
|
request: jest.fn(),
|
|
21
|
+
workPageUrl: jest.fn(
|
|
22
|
+
(id: string) => `https://withpica.com/directory/works/${id}`,
|
|
23
|
+
),
|
|
21
24
|
} as any;
|
|
22
25
|
|
|
23
26
|
worksTools = new DirectoryWorksTools(mockClient);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "@jest/globals";
|
|
4
|
+
import { formatError, McpServerError } from "../../utils/errors.js";
|
|
5
|
+
|
|
6
|
+
describe("directory-mcp formatError structured-body decode", () => {
|
|
7
|
+
it("keeps McpServerError code + message", () => {
|
|
8
|
+
const result = formatError(
|
|
9
|
+
new McpServerError("Tool blew up", "TOOL_EXECUTION_ERROR"),
|
|
10
|
+
);
|
|
11
|
+
const parsed = JSON.parse(result.text);
|
|
12
|
+
expect(parsed.error).toBe("TOOL_EXECUTION_ERROR");
|
|
13
|
+
expect(parsed.message).toBe("Tool blew up");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// PICA's api-response.ts errorResponse() emits a nested body where `error`
|
|
17
|
+
// is an OBJECT: { success: false, error: { code, message, details } }. The
|
|
18
|
+
// mcp-sdk wraps it as `ApiError("API request failed: <status> <body>")`.
|
|
19
|
+
// Without decoding, the whole thing collapsed to UNKNOWN_ERROR.
|
|
20
|
+
it("surfaces code+message from a wrapped nested { error: { code, message } } body", () => {
|
|
21
|
+
const error = {
|
|
22
|
+
status: 404,
|
|
23
|
+
message:
|
|
24
|
+
"API 404 not found: " +
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
success: false,
|
|
27
|
+
error: {
|
|
28
|
+
code: "WORK_NOT_FOUND",
|
|
29
|
+
message: "no work matches that ISWC",
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
const result = formatError(error);
|
|
34
|
+
const parsed = JSON.parse(result.text);
|
|
35
|
+
expect(parsed.error).toBe("WORK_NOT_FOUND");
|
|
36
|
+
expect(parsed.message).toBe("no work matches that ISWC");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("surfaces code+message from the flat { code, error } convention", () => {
|
|
40
|
+
const error = {
|
|
41
|
+
message: JSON.stringify({ code: "BAD_INPUT", error: "nope" }),
|
|
42
|
+
};
|
|
43
|
+
const result = formatError(error);
|
|
44
|
+
const parsed = JSON.parse(result.text);
|
|
45
|
+
expect(parsed.error).toBe("BAD_INPUT");
|
|
46
|
+
expect(parsed.message).toBe("nope");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("recovers the real message from a coded-less body (no UNKNOWN noise)", () => {
|
|
50
|
+
const error = {
|
|
51
|
+
message: JSON.stringify({
|
|
52
|
+
success: false,
|
|
53
|
+
error: { message: "name required" },
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
const result = formatError(error);
|
|
57
|
+
const parsed = JSON.parse(result.text);
|
|
58
|
+
expect(parsed.error).toBe("UNKNOWN_ERROR");
|
|
59
|
+
expect(parsed.message).toBe("name required");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("falls through to UNKNOWN_ERROR for a plain non-JSON error", () => {
|
|
63
|
+
const result = formatError(new Error("some weird thing"));
|
|
64
|
+
const parsed = JSON.parse(result.text);
|
|
65
|
+
expect(parsed.error).toBe("UNKNOWN_ERROR");
|
|
66
|
+
expect(parsed.message).toBe("some weird thing");
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
4
4
|
const MAX_RETRIES = 2;
|
|
@@ -37,7 +37,9 @@ export class DirectoryClient {
|
|
|
37
37
|
return await fetch(url, { ...init, signal: controller.signal });
|
|
38
38
|
} catch (err: any) {
|
|
39
39
|
if (err.name === "AbortError") {
|
|
40
|
-
throw new Error(
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Request timed out after ${timeoutMs}ms: ${init.method ?? "GET"} ${url}`,
|
|
42
|
+
);
|
|
41
43
|
}
|
|
42
44
|
throw err;
|
|
43
45
|
} finally {
|
|
@@ -45,6 +47,40 @@ export class DirectoryClient {
|
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** The public page for a directory work, for a human to open. */
|
|
51
|
+
workPageUrl(workId: string): string {
|
|
52
|
+
return `${this.baseUrl}/directory/works/${encodeURIComponent(workId)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Send a licensing enquiry through the public enquiry route (the same one
|
|
57
|
+
* the directory's web form uses). ONE attempt, never retried: a retry after
|
|
58
|
+
* a timeout or a 5xx could store the enquiry twice and email the owner
|
|
59
|
+
* twice. Resolves with the status and parsed body for every HTTP answer,
|
|
60
|
+
* so the caller can tell a refusal (400/403/404/429) from a failure;
|
|
61
|
+
* throws only when no answer arrived.
|
|
62
|
+
*/
|
|
63
|
+
async sendLicensingEnquiry(
|
|
64
|
+
body: Record<string, unknown>,
|
|
65
|
+
): Promise<{ status: number; json: any }> {
|
|
66
|
+
const url = `${this.baseUrl}/api/shop/license-enquiries`;
|
|
67
|
+
if (this.debug) console.error(`[Directory MCP] POST ${url}`);
|
|
68
|
+
const response = await this.fetchWithTimeout(
|
|
69
|
+
url,
|
|
70
|
+
{
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: {
|
|
73
|
+
Accept: "application/json",
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify(body),
|
|
77
|
+
},
|
|
78
|
+
DEFAULT_TIMEOUT_MS,
|
|
79
|
+
);
|
|
80
|
+
const json: any = await response.json().catch(() => null);
|
|
81
|
+
return { status: response.status, json };
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
|
49
85
|
const url = new URL(`${this.baseUrl}/api/public/directory${path}`);
|
|
50
86
|
if (params) {
|
package/src/config.ts
CHANGED
package/src/index.ts
CHANGED
package/src/lib/changelog.ts
CHANGED
package/src/prompts/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Prompt Registry for Directory MCP Server
|
|
@@ -197,7 +197,7 @@ If I want to BROWSE what's in the directory:
|
|
|
197
197
|
|
|
198
198
|
Tell me which workflow you chose and why, in one sentence. Offer alternatives.
|
|
199
199
|
|
|
200
|
-
Important:
|
|
200
|
+
Important: The directory's catalogue is read-only: I can search and look up, but I can't create or modify anything in it. The one thing I can send, and only if you ask, is a licensing enquiry to a work's owner, with your own name and email. Only works from organisations that opted into the directory are visible.`,
|
|
201
201
|
},
|
|
202
202
|
},
|
|
203
203
|
],
|