@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Public Question Atlas — ADR-229 Decision 1.
|
|
@@ -563,4 +563,19 @@ export const PUBLIC_ATLAS: AtlasEntry[] = [
|
|
|
563
563
|
],
|
|
564
564
|
resolves_to: { kind: "tool", name: "directory_release_notes" },
|
|
565
565
|
},
|
|
566
|
+
|
|
567
|
+
// ─────────────────────────────────────────────────────────────────
|
|
568
|
+
// Reaching an owner — the directory's one write
|
|
569
|
+
// ─────────────────────────────────────────────────────────────────
|
|
570
|
+
{
|
|
571
|
+
question: "can I license this song?",
|
|
572
|
+
synonyms: [
|
|
573
|
+
"ask the owner if I can use this",
|
|
574
|
+
"send a licensing enquiry",
|
|
575
|
+
"contact the rights holder",
|
|
576
|
+
"who do I ask to license this?",
|
|
577
|
+
"I want to use this track in my project",
|
|
578
|
+
],
|
|
579
|
+
resolves_to: { kind: "tool", name: "directory_send_licensing_enquiry" },
|
|
580
|
+
},
|
|
566
581
|
];
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
export const DIRECTORY_PRIMER = `# PICA Directory — Public Music Catalog Search
|
|
4
4
|
|
|
5
|
-
Read
|
|
5
|
+
Read access to published music catalog data, plus one write: sending a
|
|
6
|
+
licensing enquiry to a work's owner when the user asks. No authentication
|
|
7
|
+
required.
|
|
6
8
|
Works and people are only visible if the owning organisation has opted into
|
|
7
9
|
the directory.
|
|
8
10
|
|
|
@@ -17,9 +19,11 @@ research, or identifier lookup.
|
|
|
17
19
|
- Search people by name, ISNI, IPI
|
|
18
20
|
- Look up works by recording ISRC (directory_lookup_isrc)
|
|
19
21
|
- Search recordings by audio characteristics (BPM, key, mood, energy)
|
|
22
|
+
- Send a licensing enquiry to a work's owner (directory_send_licensing_enquiry),
|
|
23
|
+
only when the user asks and gives their own name, email and project
|
|
20
24
|
|
|
21
25
|
## What You Cannot Do
|
|
22
|
-
- Create, update, or delete
|
|
26
|
+
- Create, update, or delete catalogue data (the catalogue is read-only)
|
|
23
27
|
- Access unpublished works or private catalog data
|
|
24
28
|
- See financial data, agreements, or internal metadata
|
|
25
29
|
|
package/src/server.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- Copyright (c)
|
|
1
|
+
<!-- Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. -->
|
|
2
2
|
---
|
|
3
3
|
name: find-music-for-sync-brief
|
|
4
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.
|
|
@@ -79,7 +79,7 @@ Be honest if the directory is small for a specific style — PICA's directory gr
|
|
|
79
79
|
|
|
80
80
|
## What not to do
|
|
81
81
|
|
|
82
|
-
- Don't claim to license tracks — this skill returns a shortlist, not a license.
|
|
82
|
+
- Don't claim to license tracks — this skill returns a shortlist, not a license. When the user wants to approach an owner, offer `directory_send_licensing_enquiry`, and send it only once they say yes and give their own name, email and project description. Never invent those details.
|
|
83
83
|
- Don't speculate about pricing — sync fees are negotiated per-placement
|
|
84
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
85
|
- Don't return tracks with no attested credits without flagging them as "rights chain unverified"
|
package/src/skills/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
|
* GENERATED FILE — DO NOT EDIT BY HAND.
|
|
@@ -26,7 +26,7 @@ export const SKILLS: Record<string, Skill> = {
|
|
|
26
26
|
audience: "sync supervisor / music library / ad agency / film & TV music team",
|
|
27
27
|
tools_required: ["directory_search_recordings","directory_lookup_work","directory_lookup_person"],
|
|
28
28
|
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).",
|
|
29
|
-
body: "# find-music-for-sync-brief\n\nHelp 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.\n\nThis 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.\n\n## Step 1 — Translate the brief\n\nMost sync briefs are described in natural language. Translate before searching:\n\n| User says | Search parameters |\n|---|---|\n| upbeat / uplifting / driving | `min_energy: 0.6`, `min_danceability: 0.5` |\n| dark / moody / brooding | `max_energy: 0.4`, `key_mode: minor` |\n| chill / ambient / background | `max_energy: 0.4`, `max_bpm: 100` |\n| epic / cinematic | `min_energy: 0.7`, often longer durations |\n| specific BPM (\"around 120bpm\") | `min_bpm: 115`, `max_bpm: 125` |\n| acoustic / sparse | typically lower `danceability` and `energy` |\n| vocal / instrumental | filter on `has_vocals` if available |\n\nIf 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.\n\n## Step 2 — Search\n\nCall `directory_search_recordings` with the parameters from step 1. Default limit is fine for an initial scan.\n\nIf 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.\n\n## Step 3 — Lookup full details\n\nFor the top 5-10 promising results, call `directory_lookup_work` to get:\n\n- **Who wrote it** — credits with IPI numbers (essential for the licensing contact path)\n- **Whether credits are attested** — an unattested credit means the ownership claim hasn't been verified; flag this\n- **DSP links** (Spotify, Apple Music) — the user wants to listen before shortlisting\n- **Registration score** — higher score = cleaner rights chain = easier to license\n\nFor 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.\n\n## Step 4 — Present the shortlist\n\nPresent each shortlisted track with:\n\n- **Title, artist** — what the user listens for\n- **Audio profile** — BPM, key, energy (one line, the supervisor's quick filter)\n- **Credits summary** — writers with IPI, publisher if known (this is the licensing path)\n- **DSP links** — so the user can listen\n- **Flags** — unattested credits, low registration score, missing ISRC, multiple writers (= multi-party clearance needed)\n\nIf 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.\"\n\n## Step 5 — If no results\n\nIf the search returns nothing, walk the user back through the brief:\n- Widen the BPM range first (most supervisors don't actually mind ±10 BPM)\n- Drop the key filter (key matching matters less than mood for most placements)\n- Drop the energy bounds and search by genre/mood text only\n- Suggest the user expand to adjacent moods (\"uplifting\" → also try \"warm\" or \"hopeful\")\n\nBe honest if the directory is small for a specific style — PICA's directory grows with org opt-ins; some niches are sparse.\n\n## What not to do\n\n- Don't claim to license tracks — this skill returns a shortlist, not a license.
|
|
29
|
+
body: "# find-music-for-sync-brief\n\nHelp 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.\n\nThis 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.\n\n## Step 1 — Translate the brief\n\nMost sync briefs are described in natural language. Translate before searching:\n\n| User says | Search parameters |\n|---|---|\n| upbeat / uplifting / driving | `min_energy: 0.6`, `min_danceability: 0.5` |\n| dark / moody / brooding | `max_energy: 0.4`, `key_mode: minor` |\n| chill / ambient / background | `max_energy: 0.4`, `max_bpm: 100` |\n| epic / cinematic | `min_energy: 0.7`, often longer durations |\n| specific BPM (\"around 120bpm\") | `min_bpm: 115`, `max_bpm: 125` |\n| acoustic / sparse | typically lower `danceability` and `energy` |\n| vocal / instrumental | filter on `has_vocals` if available |\n\nIf 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.\n\n## Step 2 — Search\n\nCall `directory_search_recordings` with the parameters from step 1. Default limit is fine for an initial scan.\n\nIf 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.\n\n## Step 3 — Lookup full details\n\nFor the top 5-10 promising results, call `directory_lookup_work` to get:\n\n- **Who wrote it** — credits with IPI numbers (essential for the licensing contact path)\n- **Whether credits are attested** — an unattested credit means the ownership claim hasn't been verified; flag this\n- **DSP links** (Spotify, Apple Music) — the user wants to listen before shortlisting\n- **Registration score** — higher score = cleaner rights chain = easier to license\n\nFor 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.\n\n## Step 4 — Present the shortlist\n\nPresent each shortlisted track with:\n\n- **Title, artist** — what the user listens for\n- **Audio profile** — BPM, key, energy (one line, the supervisor's quick filter)\n- **Credits summary** — writers with IPI, publisher if known (this is the licensing path)\n- **DSP links** — so the user can listen\n- **Flags** — unattested credits, low registration score, missing ISRC, multiple writers (= multi-party clearance needed)\n\nIf 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.\"\n\n## Step 5 — If no results\n\nIf the search returns nothing, walk the user back through the brief:\n- Widen the BPM range first (most supervisors don't actually mind ±10 BPM)\n- Drop the key filter (key matching matters less than mood for most placements)\n- Drop the energy bounds and search by genre/mood text only\n- Suggest the user expand to adjacent moods (\"uplifting\" → also try \"warm\" or \"hopeful\")\n\nBe honest if the directory is small for a specific style — PICA's directory grows with org opt-ins; some niches are sparse.\n\n## What not to do\n\n- Don't claim to license tracks — this skill returns a shortlist, not a license. When the user wants to approach an owner, offer `directory_send_licensing_enquiry`, and send it only once they say yes and give their own name, email and project description. Never invent those details.\n- Don't speculate about pricing — sync fees are negotiated per-placement\n- 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\n- Don't return tracks with no attested credits without flagging them as \"rights chain unverified\"\n\n## Follow-on skills\n\n- `due-diligence-on-creator` (Stage 2 candidate) — for deeper research on a specific writer before reaching out\n",
|
|
30
30
|
},
|
|
31
31
|
};
|
|
32
32
|
|
package/src/tools/chain.ts
CHANGED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import { DirectoryClient } from "../client.js";
|
|
4
|
+
import { ToolDefinition, ToolExecutor, ToolResult } from "./index.js";
|
|
5
|
+
|
|
6
|
+
const PROJECT_TYPES = [
|
|
7
|
+
"film",
|
|
8
|
+
"tv series",
|
|
9
|
+
"commercial",
|
|
10
|
+
"trailer",
|
|
11
|
+
"video game",
|
|
12
|
+
"podcast",
|
|
13
|
+
"social media",
|
|
14
|
+
"other",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** The enquiry route answers `{ error: "…" }` or `{ error: { message } }`. */
|
|
18
|
+
function errorMessage(json: any): string | null {
|
|
19
|
+
const e = json?.error;
|
|
20
|
+
if (typeof e === "string") return e;
|
|
21
|
+
if (e && typeof e.message === "string") return e.message;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The route stores the enquiry and THEN sends the emails, so a 5xx or a lost
|
|
27
|
+
* answer can come after it was stored. Saying "not sent" there would invite a
|
|
28
|
+
* resend, and a second enquiry and email to the owner.
|
|
29
|
+
*/
|
|
30
|
+
function outcomeUnknown(detail: string): ToolResult {
|
|
31
|
+
const reason =
|
|
32
|
+
"Outcome unknown: the enquiry may already have reached the owner. " +
|
|
33
|
+
"Do not resend without checking with the user first.";
|
|
34
|
+
return {
|
|
35
|
+
content: [{ type: "text", text: `${reason} (${detail})` }],
|
|
36
|
+
structuredContent: { sent: null, outcome: "unknown", reason, detail },
|
|
37
|
+
isError: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The directory's only non-read tool: send a licensing enquiry to the owner
|
|
43
|
+
* of a work listed in the public directory. It goes through the same public
|
|
44
|
+
* route as the directory's web form (POST /api/shop/license-enquiries), so
|
|
45
|
+
* the same rules apply: the work must be listed, the owner must not have
|
|
46
|
+
* switched enquiries off, and a budget is optional.
|
|
47
|
+
*/
|
|
48
|
+
export class DirectoryEnquiryTools {
|
|
49
|
+
private client: DirectoryClient;
|
|
50
|
+
|
|
51
|
+
constructor(client: DirectoryClient) {
|
|
52
|
+
this.client = client;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getTools(): Array<{ definition: ToolDefinition; executor: ToolExecutor }> {
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
definition: {
|
|
59
|
+
name: "directory_send_licensing_enquiry",
|
|
60
|
+
tier: "write",
|
|
61
|
+
description:
|
|
62
|
+
"Use when the user asks: 'ask the owner if I can license this', " +
|
|
63
|
+
"'send a licensing enquiry for X', 'contact the rights holder about using this song'. " +
|
|
64
|
+
"Sends a licensing enquiry to the owner of a work listed in the public directory. " +
|
|
65
|
+
"The enquiry is passed to the owner, who can reply to the user by email (the user's address is " +
|
|
66
|
+
"shared with them); a confirmation email to the user is usually sent but not guaranteed. " +
|
|
67
|
+
"Nothing is agreed or priced: it only opens the conversation. " +
|
|
68
|
+
"ONLY call after the user has explicitly asked to send it and has given their own name, " +
|
|
69
|
+
"email and a description of the project. Never invent these, and never send on their behalf " +
|
|
70
|
+
"without asking. Get work_id from directory_lookup_work, directory_list_works or directory_chain. " +
|
|
71
|
+
"A 403 means the owner is not taking enquiries; a 404 means the work is not publicly listed; " +
|
|
72
|
+
"an unknown outcome means it may already have been sent, so never resend without asking. " +
|
|
73
|
+
"NOT FOR: looking up who owns a song (use directory_lookup_work). " +
|
|
74
|
+
"→ then: directory_lookup_work (the work page and credits, to share with the user)",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
work_id: {
|
|
79
|
+
type: "string",
|
|
80
|
+
description: "The work's UUID from the directory.",
|
|
81
|
+
},
|
|
82
|
+
contact_name: {
|
|
83
|
+
type: "string",
|
|
84
|
+
description: "The user's own name, as they gave it.",
|
|
85
|
+
},
|
|
86
|
+
contact_email: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description:
|
|
89
|
+
"The user's own email address. The owner will reply to it.",
|
|
90
|
+
},
|
|
91
|
+
company_name: {
|
|
92
|
+
type: "string",
|
|
93
|
+
description: "The user's company or production (optional).",
|
|
94
|
+
},
|
|
95
|
+
project_type: {
|
|
96
|
+
type: "string",
|
|
97
|
+
enum: PROJECT_TYPES,
|
|
98
|
+
description: "The kind of project.",
|
|
99
|
+
},
|
|
100
|
+
project_description: {
|
|
101
|
+
type: "string",
|
|
102
|
+
description:
|
|
103
|
+
"How the music would be used, in the user's words.",
|
|
104
|
+
},
|
|
105
|
+
territory: {
|
|
106
|
+
type: "string",
|
|
107
|
+
description:
|
|
108
|
+
"Where it would be used (optional; omit if the user didn't say).",
|
|
109
|
+
},
|
|
110
|
+
term: {
|
|
111
|
+
type: "string",
|
|
112
|
+
description:
|
|
113
|
+
"For how long (optional; omit if the user didn't say).",
|
|
114
|
+
},
|
|
115
|
+
budget_amount: {
|
|
116
|
+
type: "number",
|
|
117
|
+
description:
|
|
118
|
+
"Proposed fee, only if the user named one (optional).",
|
|
119
|
+
},
|
|
120
|
+
budget_currency: {
|
|
121
|
+
type: "string",
|
|
122
|
+
description: "Three-letter currency code (default GBP).",
|
|
123
|
+
},
|
|
124
|
+
budget_notes: {
|
|
125
|
+
type: "string",
|
|
126
|
+
description:
|
|
127
|
+
"Anything the user said about budget that isn't a single amount, e.g. a range.",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
required: [
|
|
131
|
+
"work_id",
|
|
132
|
+
"contact_name",
|
|
133
|
+
"contact_email",
|
|
134
|
+
"project_type",
|
|
135
|
+
"project_description",
|
|
136
|
+
],
|
|
137
|
+
additionalProperties: false,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
executor: this.sendEnquiry.bind(this),
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async sendEnquiry(args: Record<string, any>): Promise<ToolResult> {
|
|
146
|
+
const workId = String(args.work_id ?? "");
|
|
147
|
+
let answer: { status: number; json: any };
|
|
148
|
+
try {
|
|
149
|
+
answer = await this.client.sendLicensingEnquiry({
|
|
150
|
+
workId,
|
|
151
|
+
contactName: args.contact_name,
|
|
152
|
+
contactEmail: args.contact_email,
|
|
153
|
+
companyName: args.company_name,
|
|
154
|
+
projectType: args.project_type,
|
|
155
|
+
projectDescription: args.project_description,
|
|
156
|
+
// Not specified is said plainly, never guessed.
|
|
157
|
+
territory: args.territory || "not specified",
|
|
158
|
+
duration: args.term || "not specified",
|
|
159
|
+
proposedBudgetAmount:
|
|
160
|
+
typeof args.budget_amount === "number" ? args.budget_amount : null,
|
|
161
|
+
proposedBudgetCurrency: args.budget_currency || "GBP",
|
|
162
|
+
budgetNotes: args.budget_notes,
|
|
163
|
+
});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
// No answer at all (timeout, network): the route may still have stored it.
|
|
166
|
+
return outcomeUnknown((err as Error).message);
|
|
167
|
+
}
|
|
168
|
+
const { status, json } = answer;
|
|
169
|
+
const page = this.client.workPageUrl(workId);
|
|
170
|
+
|
|
171
|
+
if (status >= 200 && status < 300 && json?.data?.id) {
|
|
172
|
+
return {
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: "text",
|
|
176
|
+
text:
|
|
177
|
+
`Enquiry passed to the owner (reference ${json.data.id}). ` +
|
|
178
|
+
`They can reply to ${args.contact_email} if they want to take it further; ` +
|
|
179
|
+
`nothing has been agreed. Work page: ${page}`,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
structuredContent: {
|
|
183
|
+
sent: true,
|
|
184
|
+
enquiry_id: json.data.id,
|
|
185
|
+
status: json.data.status,
|
|
186
|
+
work_page_url: page,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A 5xx, or a 2xx without the reference, may have stored the enquiry.
|
|
192
|
+
if (status >= 500 || (status >= 200 && status < 300))
|
|
193
|
+
return outcomeUnknown(`HTTP ${status}`);
|
|
194
|
+
|
|
195
|
+
const reason =
|
|
196
|
+
status === 403
|
|
197
|
+
? "The owner is not taking licensing enquiries for this work."
|
|
198
|
+
: status === 404
|
|
199
|
+
? "That work is not listed in the public directory."
|
|
200
|
+
: status === 429
|
|
201
|
+
? "Too many enquiries were sent recently. Try again later."
|
|
202
|
+
: (errorMessage(json) ??
|
|
203
|
+
`The enquiry was not sent (HTTP ${status}).`);
|
|
204
|
+
return {
|
|
205
|
+
content: [{ type: "text", text: `Not sent. ${reason}` }],
|
|
206
|
+
structuredContent: { sent: false, http_status: status, reason },
|
|
207
|
+
isError: true,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
import { DirectoryClient } from "../client.js";
|
|
4
4
|
import { DirectoryWorksTools } from "./works.js";
|
|
@@ -8,13 +8,15 @@ import { DirectoryRecordingsTools } from "./recordings.js";
|
|
|
8
8
|
import { DirectoryChainTools } from "./chain.js";
|
|
9
9
|
import { DirectorySkillsTools } from "./skills.js";
|
|
10
10
|
import { DirectoryReleaseNotesTools } from "./release-notes.js";
|
|
11
|
+
import { DirectoryEnquiryTools } from "./enquiries.js";
|
|
11
12
|
import { formatError, logError, ToolExecutionError } from "../utils/errors.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* ADR-230 — authority tier on every directory_* tool. Sister of the
|
|
15
|
-
* creator + team MCP's `Tier` declaration. Every directory tool is
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* creator + team MCP's `Tier` declaration. Every directory tool is `read`
|
|
17
|
+
* (public catalogue lookup) except directory_send_licensing_enquiry
|
|
18
|
+
* (`write`, tools/enquiries.ts), which sends an enquiry through the same
|
|
19
|
+
* public route as the directory's web form.
|
|
18
20
|
*/
|
|
19
21
|
export type Tier = "read" | "draft" | "write" | "destructive";
|
|
20
22
|
|
|
@@ -56,6 +58,7 @@ export class ToolRegistry {
|
|
|
56
58
|
new DirectoryChainTools(client),
|
|
57
59
|
new DirectorySkillsTools(),
|
|
58
60
|
new DirectoryReleaseNotesTools(client),
|
|
61
|
+
new DirectoryEnquiryTools(client),
|
|
59
62
|
];
|
|
60
63
|
|
|
61
64
|
for (const group of groups) {
|
package/src/tools/people.ts
CHANGED
package/src/tools/recordings.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
/* eslint-disable @typescript-eslint/no-explicit-any -- ToolExecutor signature uses Record<string, any> */
|
|
4
4
|
|
|
@@ -58,7 +58,8 @@ export class DirectoryReleaseNotesTools {
|
|
|
58
58
|
"directory_list_works / directory_chain for those). " +
|
|
59
59
|
"Returns { package, current_version, releases: [{ version, date, sections, markdown }], resource_uri }. " +
|
|
60
60
|
"Defaults to the last 3 releases; use `since_version` to bound the output " +
|
|
61
|
-
"or `sections` to filter to Added/Fixed/etc."
|
|
61
|
+
"or `sections` to filter to Added/Fixed/etc. " +
|
|
62
|
+
"→ then: directory_search (explore anything a release note mentions)",
|
|
62
63
|
inputSchema: {
|
|
63
64
|
type: "object",
|
|
64
65
|
properties: {
|
package/src/tools/search.ts
CHANGED
package/src/tools/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
|
* Directory MCP skill tools — ADR-140 Phase 2b
|
|
@@ -27,7 +27,8 @@ export class DirectorySkillsTools {
|
|
|
27
27
|
description:
|
|
28
28
|
"Use when the user asks: 'what skills do you have?', 'what can you do?', 'show me your methodologies', 'how do I find music here?'. " +
|
|
29
29
|
"Returns the list of downloadable directory skill methodologies — name, description, trigger phrases. " +
|
|
30
|
-
"Cheap to call.
|
|
30
|
+
"Cheap to call. " +
|
|
31
|
+
"→ then: directory_skill_get (load a specific skill's full methodology body)",
|
|
31
32
|
inputSchema: {
|
|
32
33
|
type: "object",
|
|
33
34
|
properties: {},
|
|
@@ -43,7 +44,8 @@ export class DirectorySkillsTools {
|
|
|
43
44
|
description:
|
|
44
45
|
"Use when the user asks: 'what skills do you have?', 'what can you do?', 'show me your methodologies'. " +
|
|
45
46
|
"Returns the full methodology body for one directory skill — markdown with step-by-step instructions and tool chains. " +
|
|
46
|
-
"Call directory_skill_list first to see available skill names, then directory_skill_get to load one. Read once at the start of the workflow; do not re-fetch on every step."
|
|
47
|
+
"Call directory_skill_list first to see available skill names, then directory_skill_get to load one. Read once at the start of the workflow; do not re-fetch on every step. " +
|
|
48
|
+
"→ then: directory_search (run the loaded methodology against the catalogue)",
|
|
47
49
|
inputSchema: {
|
|
48
50
|
type: "object",
|
|
49
51
|
properties: {
|
package/src/tools/works.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
import { DirectoryClient } from "../client.js";
|
|
4
4
|
import { ToolDefinition, ToolExecutor } from "./index.js";
|
|
@@ -243,11 +243,29 @@ export class DirectoryWorksTools {
|
|
|
243
243
|
lines.push("");
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// How to reach the owner. Enquiries are opt-out (only an explicit false
|
|
247
|
+
// closes them), the same test the enquiry route applies.
|
|
248
|
+
const workId: string | undefined = data.work_id || data.id;
|
|
249
|
+
const pageUrl = workId ? this.client.workPageUrl(workId) : undefined;
|
|
250
|
+
const enquiriesOpen = data.contact_enabled !== false;
|
|
251
|
+
lines.push("### Licensing");
|
|
252
|
+
if (pageUrl) lines.push(`- Work page: ${pageUrl}`);
|
|
253
|
+
lines.push(
|
|
254
|
+
enquiriesOpen && workId
|
|
255
|
+
? `- Enquiries open: with the user's go-ahead, send one via directory_send_licensing_enquiry (work_id ${workId}).`
|
|
256
|
+
: "- The owner is not taking licensing enquiries for this work.",
|
|
257
|
+
);
|
|
258
|
+
lines.push("");
|
|
259
|
+
|
|
246
260
|
const markdown = lines.join("\n");
|
|
247
261
|
|
|
248
262
|
return {
|
|
249
263
|
content: [{ type: "text", text: markdown }],
|
|
250
|
-
structuredContent:
|
|
264
|
+
structuredContent: {
|
|
265
|
+
...(data as Record<string, unknown>),
|
|
266
|
+
work_page_url: pageUrl ?? null,
|
|
267
|
+
enquiries_open: enquiriesOpen && Boolean(workId),
|
|
268
|
+
},
|
|
251
269
|
};
|
|
252
270
|
}
|
|
253
271
|
|
package/src/utils/errors.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
export class McpServerError extends Error {
|
|
4
4
|
constructor(
|
|
@@ -31,11 +31,47 @@ export function formatError(error: any): { type: string; text: string } {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Recover a structured error body so a real domain code surfaces instead of
|
|
35
|
+
// collapsing every route failure to UNKNOWN_ERROR. The mcp-sdk wraps the
|
|
36
|
+
// body inside `ApiError("API request failed: <status> <body>")`, so the JSON
|
|
37
|
+
// is not at the start of the string. PICA's api-response.ts errorResponse()
|
|
38
|
+
// emits { success: false, error: { code, message, details } } — `error` is a
|
|
39
|
+
// nested OBJECT; a few routes emit the flat { code, error } / { error_code,
|
|
40
|
+
// message } conventions. Decode all of them.
|
|
41
|
+
const rawMessage =
|
|
42
|
+
error instanceof Error
|
|
43
|
+
? error.message
|
|
44
|
+
: (error as any)?.message || String(error);
|
|
45
|
+
const structuredBody = extractEmbeddedJson(rawMessage);
|
|
46
|
+
const nestedError =
|
|
47
|
+
structuredBody?.error && typeof structuredBody.error === "object"
|
|
48
|
+
? (structuredBody.error as {
|
|
49
|
+
code?: string;
|
|
50
|
+
message?: string;
|
|
51
|
+
details?: unknown;
|
|
52
|
+
})
|
|
53
|
+
: null;
|
|
54
|
+
const bodyCode =
|
|
55
|
+
structuredBody?.error_code ?? structuredBody?.code ?? nestedError?.code;
|
|
56
|
+
const bodyMessage =
|
|
57
|
+
structuredBody?.message ??
|
|
58
|
+
nestedError?.message ??
|
|
59
|
+
(typeof structuredBody?.error === "string"
|
|
60
|
+
? structuredBody.error
|
|
61
|
+
: undefined);
|
|
62
|
+
const bodyDetails = structuredBody?.details ?? nestedError?.details;
|
|
63
|
+
|
|
64
|
+
// Structured error body from the route — propagate the domain-specific code
|
|
65
|
+
// and message rather than the "API request failed: …" wrapper noise.
|
|
66
|
+
if (bodyCode) {
|
|
35
67
|
return {
|
|
36
68
|
type: "text",
|
|
37
69
|
text: JSON.stringify(
|
|
38
|
-
{
|
|
70
|
+
{
|
|
71
|
+
error: bodyCode,
|
|
72
|
+
message: bodyMessage || rawMessage,
|
|
73
|
+
details: bodyDetails,
|
|
74
|
+
},
|
|
39
75
|
null,
|
|
40
76
|
2,
|
|
41
77
|
),
|
|
@@ -45,13 +81,45 @@ export function formatError(error: any): { type: string; text: string } {
|
|
|
45
81
|
return {
|
|
46
82
|
type: "text",
|
|
47
83
|
text: JSON.stringify(
|
|
48
|
-
{
|
|
84
|
+
{
|
|
85
|
+
error: "UNKNOWN_ERROR",
|
|
86
|
+
// A body without a code still carries the real message — surface it.
|
|
87
|
+
message: bodyMessage || rawMessage,
|
|
88
|
+
},
|
|
49
89
|
null,
|
|
50
90
|
2,
|
|
51
91
|
),
|
|
52
92
|
};
|
|
53
93
|
}
|
|
54
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Extract a JSON error body from an error message. The body may be the whole
|
|
97
|
+
* string, or — as the mcp-sdk wraps it — embedded after a prefix
|
|
98
|
+
* ("API request failed: 400 {…}"). Slices from the first `{` to the last `}`
|
|
99
|
+
* so a wrapped body is still recovered. Returns null when there is no
|
|
100
|
+
* parseable object.
|
|
101
|
+
*/
|
|
102
|
+
function extractEmbeddedJson(raw: unknown): {
|
|
103
|
+
error_code?: string;
|
|
104
|
+
code?: string;
|
|
105
|
+
message?: string;
|
|
106
|
+
// `error` is a string for { code, error: "<message>" }, an object for PICA's
|
|
107
|
+
// nested { error: { code, message, details } } shape.
|
|
108
|
+
error?: string | { code?: string; message?: string; details?: unknown };
|
|
109
|
+
details?: unknown;
|
|
110
|
+
} | null {
|
|
111
|
+
if (typeof raw !== "string") return null;
|
|
112
|
+
const first = raw.indexOf("{");
|
|
113
|
+
const last = raw.lastIndexOf("}");
|
|
114
|
+
if (first === -1 || last <= first) return null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(raw.slice(first, last + 1));
|
|
117
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
55
123
|
export function logError(context: string, error: any): void {
|
|
56
124
|
const entry: Record<string, unknown> = {
|
|
57
125
|
level: "error",
|
package/src/utils/formatting.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
|
|
3
3
|
export interface FormattedResult {
|
|
4
4
|
content: Array<{ type: string; text: string }>;
|
|
@@ -6,9 +6,15 @@ export interface FormattedResult {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export function formatAsText(data: any): FormattedResult {
|
|
9
|
+
// MCP requires structuredContent to be a RECORD — bare arrays are rejected
|
|
10
|
+
// by the SDK server with -32602. Wrap them in the standard list envelope
|
|
11
|
+
// (mirrors @withpica/mcp-utils formatAsText).
|
|
12
|
+
const envelope = Array.isArray(data)
|
|
13
|
+
? { items: data, count: data.length }
|
|
14
|
+
: data;
|
|
9
15
|
return {
|
|
10
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
11
|
-
structuredContent:
|
|
16
|
+
content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }],
|
|
17
|
+
structuredContent: envelope as Record<string, unknown>,
|
|
12
18
|
};
|
|
13
19
|
}
|
|
14
20
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"release-notes.test.d.ts","sourceRoot":"","sources":["../../../src/tools/__tests__/release-notes.test.ts"],"names":[],"mappings":""}
|