@withpica/mcp-server-directory 1.0.0 → 1.2.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 +56 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +9 -12
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/public-question-atlas.d.ts +121 -0
- package/dist/prompts/public-question-atlas.d.ts.map +1 -0
- package/dist/prompts/public-question-atlas.js +404 -0
- package/dist/prompts/public-question-atlas.js.map +1 -0
- package/dist/resources/llms-primer.d.ts.map +1 -1
- package/dist/resources/llms-primer.js +1 -0
- package/dist/resources/llms-primer.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/chain.d.ts +12 -0
- package/dist/tools/chain.d.ts.map +1 -0
- package/dist/tools/chain.js +109 -0
- package/dist/tools/chain.js.map +1 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/people.d.ts +0 -1
- package/dist/tools/people.d.ts.map +1 -1
- package/dist/tools/people.js +24 -36
- package/dist/tools/people.js.map +1 -1
- package/dist/tools/recordings.d.ts.map +1 -1
- package/dist/tools/recordings.js +8 -3
- package/dist/tools/recordings.js.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +8 -4
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/works.d.ts +0 -1
- package/dist/tools/works.d.ts.map +1 -1
- package/dist/tools/works.js +42 -42
- package/dist/tools/works.js.map +1 -1
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +1 -0
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/formatting.d.ts.map +1 -1
- package/dist/utils/formatting.js +1 -0
- package/dist/utils/formatting.js.map +1 -1
- package/jest.config.js +31 -0
- package/package.json +3 -2
- package/src/__tests__/prompts/index.test.ts +128 -0
- package/src/__tests__/prompts/prompt-eval-harness.test.ts +282 -0
- package/src/__tests__/tools/chain.test.ts +122 -0
- package/src/__tests__/tools/composability-chains.test.ts +100 -0
- package/src/__tests__/tools/people.test.ts +112 -0
- package/src/__tests__/tools/search.test.ts +94 -0
- package/src/__tests__/tools/works.test.ts +177 -0
- package/src/client.ts +128 -0
- package/src/config.ts +23 -0
- package/src/index.ts +36 -0
- package/src/prompts/index.ts +206 -0
- package/src/prompts/public-question-atlas.ts +540 -0
- package/src/resources/llms-primer.ts +35 -0
- package/src/server.ts +134 -0
- package/src/tools/chain.ts +118 -0
- package/src/tools/index.ts +83 -0
- package/src/tools/people.ts +196 -0
- package/src/tools/recordings.ts +149 -0
- package/src/tools/search.ts +66 -0
- package/src/tools/works.ts +266 -0
- package/src/utils/errors.ts +64 -0
- package/src/utils/formatting.ts +28 -0
- package/tsconfig.json +22 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
ListResourcesRequestSchema,
|
|
9
|
+
ReadResourceRequestSchema,
|
|
10
|
+
ListPromptsRequestSchema,
|
|
11
|
+
GetPromptRequestSchema,
|
|
12
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import { DirectoryClient } from "./client.js";
|
|
14
|
+
import { ServerConfig } from "./config.js";
|
|
15
|
+
import { ToolRegistry } from "./tools/index.js";
|
|
16
|
+
import { PromptRegistry } from "./prompts/index.js";
|
|
17
|
+
import { logError } from "./utils/errors.js";
|
|
18
|
+
import { DIRECTORY_PRIMER } from "./resources/llms-primer.js";
|
|
19
|
+
|
|
20
|
+
export class DirectoryMcpServer {
|
|
21
|
+
private server: Server;
|
|
22
|
+
private client: DirectoryClient;
|
|
23
|
+
private toolRegistry: ToolRegistry;
|
|
24
|
+
private promptRegistry: PromptRegistry;
|
|
25
|
+
private config: ServerConfig;
|
|
26
|
+
|
|
27
|
+
constructor(config: ServerConfig) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
|
|
30
|
+
this.client = new DirectoryClient({
|
|
31
|
+
baseUrl: config.directoryUrl,
|
|
32
|
+
debug: config.debug,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this.server = new Server(
|
|
36
|
+
{
|
|
37
|
+
name: config.serverName,
|
|
38
|
+
version: config.version,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
capabilities: {
|
|
42
|
+
tools: {},
|
|
43
|
+
resources: {},
|
|
44
|
+
prompts: {},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
this.toolRegistry = new ToolRegistry(this.client);
|
|
50
|
+
this.promptRegistry = new PromptRegistry();
|
|
51
|
+
this.setupHandlers();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private setupHandlers(): void {
|
|
55
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
56
|
+
if (this.config.debug) {
|
|
57
|
+
console.error("[Directory MCP] Listing tools");
|
|
58
|
+
}
|
|
59
|
+
return { tools: this.toolRegistry.listTools() };
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
63
|
+
return {
|
|
64
|
+
resources: [
|
|
65
|
+
{
|
|
66
|
+
uri: "llms://primer",
|
|
67
|
+
name: "PICA Domain Primer",
|
|
68
|
+
description:
|
|
69
|
+
"Plain-language description of PICA's domain model, entity relationships, and recommended agent workflows. Read this first.",
|
|
70
|
+
mimeType: "text/markdown",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
this.server.setRequestHandler(
|
|
77
|
+
ReadResourceRequestSchema,
|
|
78
|
+
async (request) => {
|
|
79
|
+
const { uri } = request.params;
|
|
80
|
+
if (uri === "llms://primer") {
|
|
81
|
+
return {
|
|
82
|
+
contents: [
|
|
83
|
+
{
|
|
84
|
+
uri: "llms://primer",
|
|
85
|
+
mimeType: "text/markdown",
|
|
86
|
+
text: DIRECTORY_PRIMER,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`Resource not found: ${uri}`);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
96
|
+
return { prompts: this.promptRegistry.listPrompts() };
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
100
|
+
const { name, arguments: args } = request.params;
|
|
101
|
+
return await this.promptRegistry.getPrompt(name, args);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
105
|
+
const { name, arguments: args } = request.params;
|
|
106
|
+
|
|
107
|
+
if (this.config.debug) {
|
|
108
|
+
console.error(`[Directory MCP] Executing tool: ${name}`, args);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
return await this.toolRegistry.executeTool(name, args || {});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
logError(`Tool execution: ${name}`, error);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async start(): Promise<void> {
|
|
121
|
+
const transport = new StdioServerTransport();
|
|
122
|
+
await this.server.connect(transport);
|
|
123
|
+
|
|
124
|
+
console.error("[Directory MCP] PICA Directory MCP Server started");
|
|
125
|
+
console.error(`[Directory MCP] Version: ${this.config.version}`);
|
|
126
|
+
console.error(`[Directory MCP] API URL: ${this.config.directoryUrl}`);
|
|
127
|
+
console.error("[Directory MCP] Ready to accept connections");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async stop(): Promise<void> {
|
|
131
|
+
await this.server.close();
|
|
132
|
+
console.error("[Directory MCP] Server stopped");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import { DirectoryClient } from "../client.js";
|
|
4
|
+
import { ToolDefinition, ToolExecutor } from "./index.js";
|
|
5
|
+
|
|
6
|
+
export class DirectoryChainTools {
|
|
7
|
+
private client: DirectoryClient;
|
|
8
|
+
|
|
9
|
+
constructor(client: DirectoryClient) {
|
|
10
|
+
this.client = client;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
getTools(): Array<{ definition: ToolDefinition; executor: ToolExecutor }> {
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
definition: {
|
|
17
|
+
name: "directory_chain",
|
|
18
|
+
tier: "read",
|
|
19
|
+
description:
|
|
20
|
+
"Use when the user asks: 'show me the rights chain for X', " +
|
|
21
|
+
"'give me everything on this song', 'what's the full picture for X?', " +
|
|
22
|
+
"'trace the chain from this writer to that work'. " +
|
|
23
|
+
"Graph lookup — resolves a query, ISWC, or ISRC to its full rights chain in one call. " +
|
|
24
|
+
"Each result bundles the work, its writers and publishers (names + roles, no IPIs or splits), " +
|
|
25
|
+
"the linked recording (ISRC, DSP presence, duration), and audio characteristics (BPM, key, energy). " +
|
|
26
|
+
"Prefer over stitching list_works + lookup_work + lookup_person when you want the whole graph " +
|
|
27
|
+
"around a track in a single round-trip. " +
|
|
28
|
+
"Supports audio-only queries (min_bpm, key, mood, etc.) when you don't have a text query. " +
|
|
29
|
+
"→ then: directory_lookup_work (deeper work detail), directory_lookup_person (research a named writer)",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
q: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description:
|
|
36
|
+
"Free-text query (work title, creator name, etc.). Minimum 2 characters.",
|
|
37
|
+
},
|
|
38
|
+
identifier: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "ISWC or ISRC — direct identifier lookup",
|
|
41
|
+
},
|
|
42
|
+
min_bpm: { type: "number", description: "Minimum BPM" },
|
|
43
|
+
max_bpm: { type: "number", description: "Maximum BPM" },
|
|
44
|
+
key: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "Musical key (e.g. 'C', 'F#', 'Bb')",
|
|
47
|
+
},
|
|
48
|
+
key_mode: {
|
|
49
|
+
type: "string",
|
|
50
|
+
enum: ["major", "minor"],
|
|
51
|
+
description: "Key mode",
|
|
52
|
+
},
|
|
53
|
+
min_energy: {
|
|
54
|
+
type: "number",
|
|
55
|
+
description: "Minimum energy (0–1)",
|
|
56
|
+
},
|
|
57
|
+
max_energy: {
|
|
58
|
+
type: "number",
|
|
59
|
+
description: "Maximum energy (0–1)",
|
|
60
|
+
},
|
|
61
|
+
mood: {
|
|
62
|
+
type: "string",
|
|
63
|
+
description: "Mood filter (exact match)",
|
|
64
|
+
},
|
|
65
|
+
limit: {
|
|
66
|
+
type: "number",
|
|
67
|
+
description: "Max results (default 10, max 20)",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
executor: this.chainLookup.bind(this),
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async chainLookup(args: Record<string, any>): Promise<any> {
|
|
78
|
+
const params: Record<string, string> = {};
|
|
79
|
+
|
|
80
|
+
if (args.q) params.q = String(args.q);
|
|
81
|
+
if (args.identifier) params.identifier = String(args.identifier);
|
|
82
|
+
|
|
83
|
+
const numericKeys = [
|
|
84
|
+
"min_bpm",
|
|
85
|
+
"max_bpm",
|
|
86
|
+
"min_energy",
|
|
87
|
+
"max_energy",
|
|
88
|
+
"limit",
|
|
89
|
+
];
|
|
90
|
+
for (const k of numericKeys) {
|
|
91
|
+
if (args[k] != null) params[k] = String(args[k]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (args.key) params.key = args.key;
|
|
95
|
+
if (args.key_mode) params.key_mode = args.key_mode;
|
|
96
|
+
if (args.mood) params.mood = args.mood;
|
|
97
|
+
|
|
98
|
+
const response: any = await this.client.request("/chain", params);
|
|
99
|
+
|
|
100
|
+
const results: any[] = response.results || [];
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
content: [
|
|
104
|
+
{
|
|
105
|
+
type: "text",
|
|
106
|
+
text:
|
|
107
|
+
results.length === 0
|
|
108
|
+
? "No chain results."
|
|
109
|
+
: `Found ${results.length} chain result(s). See structuredContent for the full graph.`,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
structuredContent: {
|
|
113
|
+
total: response.total ?? results.length,
|
|
114
|
+
results,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import { DirectoryClient } from "../client.js";
|
|
4
|
+
import { DirectoryWorksTools } from "./works.js";
|
|
5
|
+
import { DirectoryPeopleTools } from "./people.js";
|
|
6
|
+
import { DirectorySearchTools } from "./search.js";
|
|
7
|
+
import { DirectoryRecordingsTools } from "./recordings.js";
|
|
8
|
+
import { DirectoryChainTools } from "./chain.js";
|
|
9
|
+
import { formatError, logError, ToolExecutionError } from "../utils/errors.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ADR-230 — authority tier on every directory_* tool. Sister of the
|
|
13
|
+
* creator + team MCP's `Tier` declaration. Every directory tool is
|
|
14
|
+
* `read` (public catalogue lookup with no mutation surface), but the
|
|
15
|
+
* field is required for cross-surface uniformity and lint enforcement.
|
|
16
|
+
*/
|
|
17
|
+
export type Tier = "read" | "draft" | "write" | "destructive";
|
|
18
|
+
|
|
19
|
+
export interface ToolDefinition {
|
|
20
|
+
name: string;
|
|
21
|
+
/** ADR-230 — see {@link Tier}. Required on every directory tool. */
|
|
22
|
+
tier: Tier;
|
|
23
|
+
description: string;
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: string;
|
|
26
|
+
properties: Record<string, any>;
|
|
27
|
+
required?: string[];
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ToolResult {
|
|
32
|
+
content: Array<{ type: string; text: string }>;
|
|
33
|
+
structuredContent?: Record<string, unknown>;
|
|
34
|
+
isError?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ToolExecutor = (args: Record<string, any>) => Promise<any>;
|
|
38
|
+
|
|
39
|
+
export class ToolRegistry {
|
|
40
|
+
private tools: Map<
|
|
41
|
+
string,
|
|
42
|
+
{ definition: ToolDefinition; executor: ToolExecutor }
|
|
43
|
+
>;
|
|
44
|
+
|
|
45
|
+
constructor(client: DirectoryClient) {
|
|
46
|
+
this.tools = new Map();
|
|
47
|
+
|
|
48
|
+
const groups = [
|
|
49
|
+
new DirectoryWorksTools(client),
|
|
50
|
+
new DirectoryPeopleTools(client),
|
|
51
|
+
new DirectorySearchTools(client),
|
|
52
|
+
new DirectoryRecordingsTools(client),
|
|
53
|
+
new DirectoryChainTools(client),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
for (const group of groups) {
|
|
57
|
+
for (const tool of group.getTools()) {
|
|
58
|
+
this.tools.set(tool.definition.name, tool);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
listTools(): ToolDefinition[] {
|
|
64
|
+
return Array.from(this.tools.values()).map((t) => t.definition);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async executeTool(name: string, args: Record<string, any>): Promise<any> {
|
|
68
|
+
const tool = this.tools.get(name);
|
|
69
|
+
if (!tool) {
|
|
70
|
+
throw new ToolExecutionError(`Tool not found: ${name}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return await tool.executor(args);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
logError(`Tool execution: ${name}`, error);
|
|
77
|
+
return {
|
|
78
|
+
content: [formatError(error)],
|
|
79
|
+
isError: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import { DirectoryClient } from "../client.js";
|
|
4
|
+
import { ToolDefinition, ToolExecutor } from "./index.js";
|
|
5
|
+
import { formatStructuredList } from "../utils/formatting.js";
|
|
6
|
+
|
|
7
|
+
export class DirectoryPeopleTools {
|
|
8
|
+
private client: DirectoryClient;
|
|
9
|
+
|
|
10
|
+
constructor(client: DirectoryClient) {
|
|
11
|
+
this.client = client;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getTools(): Array<{ definition: ToolDefinition; executor: ToolExecutor }> {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
definition: {
|
|
18
|
+
name: "directory_list_people",
|
|
19
|
+
tier: "read",
|
|
20
|
+
description:
|
|
21
|
+
"Use when the user asks: 'list creators in PICA', 'people starting with B', " +
|
|
22
|
+
"'browse the people directory'. " +
|
|
23
|
+
"Browse and filter creators in the PICA public directory. " +
|
|
24
|
+
"Filter by free-text (q), ISNI, IPI, or starting letter (A–Z, or '#' for numeric). " +
|
|
25
|
+
"Returns paginated results. " +
|
|
26
|
+
"→ then: directory_lookup_person (full profile with works and identifiers)",
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
q: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "Text search across creator names",
|
|
33
|
+
},
|
|
34
|
+
isni: {
|
|
35
|
+
type: "string",
|
|
36
|
+
description: "Filter by ISNI identifier",
|
|
37
|
+
},
|
|
38
|
+
ipi: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Filter by IPI number",
|
|
41
|
+
},
|
|
42
|
+
letter: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description:
|
|
45
|
+
"Filter by last name starting letter (A–Z), or '#' for numeric",
|
|
46
|
+
},
|
|
47
|
+
page: {
|
|
48
|
+
type: "number",
|
|
49
|
+
description: "Page number (default: 1, 20 results per page)",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
executor: this.listPeople.bind(this),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
definition: {
|
|
58
|
+
name: "directory_lookup_person",
|
|
59
|
+
tier: "read",
|
|
60
|
+
description:
|
|
61
|
+
"Use when the user asks: 'lookup IPI 12345', 'who is creator X?', " +
|
|
62
|
+
"'tell me about creator X', 'lookup ISNI X'. " +
|
|
63
|
+
"Also call this BEFORE directory_list_works when the input person name is ambiguous " +
|
|
64
|
+
"(e.g. 'Bowie', 'Sade', 'Madonna') to surface candidate persons for disambiguation. " +
|
|
65
|
+
"Get full details of a creator by global creator ID (UUID), ISNI, IPI number, or MusicBrainz ID. " +
|
|
66
|
+
"Returns identifiers, credited works with roles, collaborator network, and verification score " +
|
|
67
|
+
"as a structured markdown summary. " +
|
|
68
|
+
"→ then: directory_lookup_work (inspect a credited work), directory_search_recordings (find their recordings by audio)",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
id: {
|
|
73
|
+
type: "string",
|
|
74
|
+
description:
|
|
75
|
+
"Global creator ID (UUID), ISNI, IPI number, or MusicBrainz ID",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ["id"],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
executor: this.lookupPerson.bind(this),
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async listPeople(args: Record<string, any>): Promise<any> {
|
|
87
|
+
const page = Math.max(args.page || 1, 1);
|
|
88
|
+
const limit = 20;
|
|
89
|
+
const offset = (page - 1) * limit;
|
|
90
|
+
|
|
91
|
+
const params: Record<string, string> = {
|
|
92
|
+
limit: String(limit),
|
|
93
|
+
offset: String(offset),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (args.q) params.q = args.q;
|
|
97
|
+
if (args.isni) params.isni = args.isni;
|
|
98
|
+
if (args.ipi) params.ipi = args.ipi;
|
|
99
|
+
if (args.letter) params.letter = args.letter;
|
|
100
|
+
|
|
101
|
+
const response: any = await this.client.request("/people", params);
|
|
102
|
+
|
|
103
|
+
return formatStructuredList(response.data || [], "creator", {
|
|
104
|
+
page,
|
|
105
|
+
total: response.pagination?.total || 0,
|
|
106
|
+
total_pages: Math.ceil((response.pagination?.total || 0) / limit),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async lookupPerson(args: Record<string, any>): Promise<any> {
|
|
111
|
+
const response: any = await this.client.request(
|
|
112
|
+
`/people/${encodeURIComponent(args.id)}`,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const data = response.data;
|
|
116
|
+
if (!data) {
|
|
117
|
+
return {
|
|
118
|
+
content: [{ type: "text", text: "Person not found." }],
|
|
119
|
+
structuredContent: {},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const lines: string[] = [];
|
|
124
|
+
|
|
125
|
+
const name = data.name || data.full_name || data.display_name || "Unknown";
|
|
126
|
+
lines.push(`## ${name}`);
|
|
127
|
+
lines.push("");
|
|
128
|
+
|
|
129
|
+
if (data.score !== undefined && data.score !== null) {
|
|
130
|
+
const tier =
|
|
131
|
+
data.score >= 80
|
|
132
|
+
? "verified"
|
|
133
|
+
: data.score >= 50
|
|
134
|
+
? "partial"
|
|
135
|
+
: "incomplete";
|
|
136
|
+
lines.push(`- **Score:** ${data.score}/100 (${tier})`);
|
|
137
|
+
lines.push("");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const identifiers: Array<{ type: string; value: string }> = [];
|
|
141
|
+
if (data.ipi || data.ipi_number)
|
|
142
|
+
identifiers.push({ type: "IPI", value: data.ipi || data.ipi_number });
|
|
143
|
+
if (data.isni) identifiers.push({ type: "ISNI", value: data.isni });
|
|
144
|
+
if (data.musicbrainz_id)
|
|
145
|
+
identifiers.push({ type: "MusicBrainz", value: data.musicbrainz_id });
|
|
146
|
+
if (data.ipn) identifiers.push({ type: "IPN", value: data.ipn });
|
|
147
|
+
|
|
148
|
+
lines.push("### Identifiers");
|
|
149
|
+
if (identifiers.length > 0) {
|
|
150
|
+
lines.push("| Type | Value |");
|
|
151
|
+
lines.push("| --- | --- |");
|
|
152
|
+
for (const id of identifiers) {
|
|
153
|
+
lines.push(`| ${id.type} | ${id.value} |`);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
lines.push("_No identifiers recorded._");
|
|
157
|
+
}
|
|
158
|
+
lines.push("");
|
|
159
|
+
|
|
160
|
+
const works: any[] = data.works || data.credited_works || [];
|
|
161
|
+
lines.push(`### Works (${works.length})`);
|
|
162
|
+
if (works.length > 0) {
|
|
163
|
+
lines.push("| Title | Role |");
|
|
164
|
+
lines.push("| --- | --- |");
|
|
165
|
+
for (const w of works) {
|
|
166
|
+
const title = w.title || "—";
|
|
167
|
+
const role = w.role || w.credit_role || "—";
|
|
168
|
+
lines.push(`| ${title} | ${role} |`);
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
lines.push("_No works recorded._");
|
|
172
|
+
}
|
|
173
|
+
lines.push("");
|
|
174
|
+
|
|
175
|
+
const collaborators: any[] = data.collaborators || [];
|
|
176
|
+
lines.push(`### Collaborators (${collaborators.length})`);
|
|
177
|
+
if (collaborators.length > 0) {
|
|
178
|
+
lines.push("| Name |");
|
|
179
|
+
lines.push("| --- |");
|
|
180
|
+
for (const c of collaborators) {
|
|
181
|
+
const colName = c.name || c.full_name || c.display_name || "—";
|
|
182
|
+
lines.push(`| ${colName} |`);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
lines.push("_No collaborators recorded._");
|
|
186
|
+
}
|
|
187
|
+
lines.push("");
|
|
188
|
+
|
|
189
|
+
const markdown = lines.join("\n");
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: "text", text: markdown }],
|
|
193
|
+
structuredContent: data as Record<string, unknown>,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import { DirectoryClient } from "../client.js";
|
|
4
|
+
import { ToolDefinition, ToolExecutor } from "./index.js";
|
|
5
|
+
import { formatStructuredList } from "../utils/formatting.js";
|
|
6
|
+
|
|
7
|
+
export class DirectoryRecordingsTools {
|
|
8
|
+
private client: DirectoryClient;
|
|
9
|
+
|
|
10
|
+
constructor(client: DirectoryClient) {
|
|
11
|
+
this.client = client;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getTools(): Array<{ definition: ToolDefinition; executor: ToolExecutor }> {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
definition: {
|
|
18
|
+
name: "directory_search_recordings",
|
|
19
|
+
tier: "read",
|
|
20
|
+
description:
|
|
21
|
+
"Use when the user asks: 'find upbeat tracks around 120 BPM', " +
|
|
22
|
+
"'find instrumental tracks in C minor', 'find energetic music for sync', " +
|
|
23
|
+
"'tracks at X BPM in [key]'. " +
|
|
24
|
+
"AUDIO-FEATURE search only — for name- or title-shaped queries use directory_lookup_work, " +
|
|
25
|
+
"directory_lookup_isrc, or directory_lookup_person, NOT this tool. " +
|
|
26
|
+
"Find tracks by BPM, key, energy, danceability, duration, and more. " +
|
|
27
|
+
"Only returns recordings from organisations that have opted into the directory. " +
|
|
28
|
+
"→ then: directory_lookup_work (credits and licensing details for a matched track)",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
min_bpm: {
|
|
33
|
+
type: "number",
|
|
34
|
+
description: "Minimum BPM (e.g. 100)",
|
|
35
|
+
},
|
|
36
|
+
max_bpm: {
|
|
37
|
+
type: "number",
|
|
38
|
+
description: "Maximum BPM (e.g. 140)",
|
|
39
|
+
},
|
|
40
|
+
key: {
|
|
41
|
+
type: "string",
|
|
42
|
+
description:
|
|
43
|
+
"Musical key (e.g. 'C', 'F#', 'Bb'). Matches the detected key.",
|
|
44
|
+
},
|
|
45
|
+
key_mode: {
|
|
46
|
+
type: "string",
|
|
47
|
+
enum: ["major", "minor"],
|
|
48
|
+
description: "Key mode — major or minor",
|
|
49
|
+
},
|
|
50
|
+
min_energy: {
|
|
51
|
+
type: "number",
|
|
52
|
+
description: "Minimum energy (0-1). Higher = more energetic.",
|
|
53
|
+
},
|
|
54
|
+
max_energy: {
|
|
55
|
+
type: "number",
|
|
56
|
+
description: "Maximum energy (0-1).",
|
|
57
|
+
},
|
|
58
|
+
min_danceability: {
|
|
59
|
+
type: "number",
|
|
60
|
+
description:
|
|
61
|
+
"Minimum danceability (0-1). Higher = more danceable.",
|
|
62
|
+
},
|
|
63
|
+
max_danceability: {
|
|
64
|
+
type: "number",
|
|
65
|
+
description: "Maximum danceability (0-1).",
|
|
66
|
+
},
|
|
67
|
+
min_duration: {
|
|
68
|
+
type: "number",
|
|
69
|
+
description: "Minimum duration in seconds.",
|
|
70
|
+
},
|
|
71
|
+
max_duration: {
|
|
72
|
+
type: "number",
|
|
73
|
+
description: "Maximum duration in seconds.",
|
|
74
|
+
},
|
|
75
|
+
vocals_only: {
|
|
76
|
+
type: "boolean",
|
|
77
|
+
description: "Only return tracks with vocals.",
|
|
78
|
+
},
|
|
79
|
+
instrumental_only: {
|
|
80
|
+
type: "boolean",
|
|
81
|
+
description: "Only return instrumental tracks.",
|
|
82
|
+
},
|
|
83
|
+
mood: {
|
|
84
|
+
type: "string",
|
|
85
|
+
description: "Filter by mood (exact match).",
|
|
86
|
+
},
|
|
87
|
+
limit: {
|
|
88
|
+
type: "number",
|
|
89
|
+
description: "Max results to return (default: 20, max: 100).",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
executor: this.searchRecordings.bind(this),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async searchRecordings(args: Record<string, any>): Promise<any> {
|
|
100
|
+
const params: Record<string, string> = {};
|
|
101
|
+
|
|
102
|
+
// Map all filters to query params
|
|
103
|
+
const numericKeys = [
|
|
104
|
+
"min_bpm",
|
|
105
|
+
"max_bpm",
|
|
106
|
+
"min_energy",
|
|
107
|
+
"max_energy",
|
|
108
|
+
"min_danceability",
|
|
109
|
+
"max_danceability",
|
|
110
|
+
"min_duration",
|
|
111
|
+
"max_duration",
|
|
112
|
+
"limit",
|
|
113
|
+
];
|
|
114
|
+
for (const k of numericKeys) {
|
|
115
|
+
if (args[k] != null) params[k] = String(args[k]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (args.key) params.key = args.key;
|
|
119
|
+
if (args.key_mode) params.key_mode = args.key_mode;
|
|
120
|
+
if (args.mood) params.mood = args.mood;
|
|
121
|
+
if (args.vocals_only) params.vocals_only = "true";
|
|
122
|
+
if (args.instrumental_only) params.instrumental_only = "true";
|
|
123
|
+
|
|
124
|
+
const response: any = await this.client.request("/recordings", params);
|
|
125
|
+
|
|
126
|
+
const recordings = response.data || [];
|
|
127
|
+
|
|
128
|
+
// Format each recording for readability
|
|
129
|
+
const formatted = recordings.map((r: any) => {
|
|
130
|
+
const parts: string[] = [`${r.title} (${r.isrc})`];
|
|
131
|
+
if (r.artist_name) parts.push(`Artist: ${r.artist_name}`);
|
|
132
|
+
if (r.bpm) parts.push(`BPM: ${Math.round(r.bpm)}`);
|
|
133
|
+
if (r.key)
|
|
134
|
+
parts.push(`Key: ${r.key}${r.key_mode ? ` ${r.key_mode}` : ""}`);
|
|
135
|
+
if (r.energy != null) parts.push(`Energy: ${r.energy}`);
|
|
136
|
+
if (r.duration_seconds)
|
|
137
|
+
parts.push(`Duration: ${Math.round(r.duration_seconds)}s`);
|
|
138
|
+
if (r.has_vocals != null)
|
|
139
|
+
parts.push(r.has_vocals ? "Vocals: yes" : "Instrumental");
|
|
140
|
+
if (r.owner_org_name) parts.push(`Owner: ${r.owner_org_name}`);
|
|
141
|
+
if (r.work_title) parts.push(`Work: ${r.work_title}`);
|
|
142
|
+
return { ...r, _summary: parts.join(" | ") };
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return formatStructuredList(formatted, "recording", {
|
|
146
|
+
filters: args,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|