@withpica/mcp-server-directory 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +1 -0
  4. package/dist/client.js.map +1 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +1 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/prompts/index.d.ts +6 -5
  11. package/dist/prompts/index.d.ts.map +1 -1
  12. package/dist/prompts/index.js +136 -96
  13. package/dist/prompts/index.js.map +1 -1
  14. package/dist/resources/llms-primer.d.ts.map +1 -1
  15. package/dist/resources/llms-primer.js +1 -0
  16. package/dist/resources/llms-primer.js.map +1 -1
  17. package/dist/server.d.ts.map +1 -1
  18. package/dist/server.js +1 -0
  19. package/dist/server.js.map +1 -1
  20. package/dist/tools/index.d.ts.map +1 -1
  21. package/dist/tools/index.js +1 -0
  22. package/dist/tools/index.js.map +1 -1
  23. package/dist/tools/people.d.ts.map +1 -1
  24. package/dist/tools/people.js +1 -0
  25. package/dist/tools/people.js.map +1 -1
  26. package/dist/tools/recordings.d.ts.map +1 -1
  27. package/dist/tools/recordings.js +1 -0
  28. package/dist/tools/recordings.js.map +1 -1
  29. package/dist/tools/search.d.ts.map +1 -1
  30. package/dist/tools/search.js +1 -0
  31. package/dist/tools/search.js.map +1 -1
  32. package/dist/tools/works.d.ts.map +1 -1
  33. package/dist/tools/works.js +1 -0
  34. package/dist/tools/works.js.map +1 -1
  35. package/dist/utils/errors.d.ts.map +1 -1
  36. package/dist/utils/errors.js +1 -0
  37. package/dist/utils/errors.js.map +1 -1
  38. package/dist/utils/formatting.d.ts.map +1 -1
  39. package/dist/utils/formatting.js +1 -0
  40. package/dist/utils/formatting.js.map +1 -1
  41. package/jest.config.js +31 -0
  42. package/package.json +1 -1
  43. package/src/__tests__/prompts/index.test.ts +145 -0
  44. package/src/__tests__/prompts/prompt-eval-harness.test.ts +251 -0
  45. package/src/__tests__/tools/composability-chains.test.ts +98 -0
  46. package/src/__tests__/tools/people.test.ts +106 -0
  47. package/src/__tests__/tools/search.test.ts +94 -0
  48. package/src/__tests__/tools/works.test.ts +148 -0
  49. package/src/client.ts +128 -0
  50. package/src/config.ts +23 -0
  51. package/src/index.ts +36 -0
  52. package/src/prompts/index.ts +250 -0
  53. package/src/resources/llms-primer.ts +35 -0
  54. package/src/server.ts +134 -0
  55. package/src/tools/index.ts +71 -0
  56. package/src/tools/people.ts +215 -0
  57. package/src/tools/recordings.ts +145 -0
  58. package/src/tools/search.ts +63 -0
  59. package/src/tools/works.ts +273 -0
  60. package/src/utils/errors.ts +64 -0
  61. package/src/utils/formatting.ts +28 -0
  62. package/tsconfig.json +22 -0
@@ -0,0 +1,215 @@
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 { formatAsText, 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
+ description:
20
+ "Browse and filter creators in the PICA public directory. " +
21
+ "Search by name, ISNI, or IPI number. Returns paginated results. " +
22
+ "→ then: directory_lookup_person_full (full profile with works and identifiers)",
23
+ inputSchema: {
24
+ type: "object",
25
+ properties: {
26
+ q: {
27
+ type: "string",
28
+ description: "Text search across creator names",
29
+ },
30
+ isni: {
31
+ type: "string",
32
+ description: "Filter by ISNI identifier",
33
+ },
34
+ ipi: {
35
+ type: "string",
36
+ description: "Filter by IPI number",
37
+ },
38
+ page: {
39
+ type: "number",
40
+ description: "Page number (default: 1, 20 results per page)",
41
+ },
42
+ },
43
+ },
44
+ },
45
+ executor: this.listPeople.bind(this),
46
+ },
47
+ {
48
+ definition: {
49
+ name: "directory_lookup_person",
50
+ description:
51
+ "Get full details of a single creator by their global creator ID, ISNI, IPI, or MusicBrainz ID. " +
52
+ "Returns roles, works, identifiers, and profile information. " +
53
+ "→ then: directory_lookup_person_full (agent-optimised markdown), directory_lookup_work_full (inspect a credited work)",
54
+ inputSchema: {
55
+ type: "object",
56
+ properties: {
57
+ id: {
58
+ type: "string",
59
+ description:
60
+ "Global creator ID (UUID), ISNI, IPI number, or MusicBrainz ID",
61
+ },
62
+ },
63
+ required: ["id"],
64
+ },
65
+ },
66
+ executor: this.lookupPerson.bind(this),
67
+ },
68
+ {
69
+ definition: {
70
+ name: "directory_lookup_person_full",
71
+ description:
72
+ "Get complete details about a person in the public directory — identifiers (IPI, ISNI), " +
73
+ "credited works with roles, collaborator network, and verification score. " +
74
+ "Returns a structured markdown summary. " +
75
+ "→ then: directory_lookup_work_full (inspect a specific work), directory_search_recordings (find their recordings by audio)",
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ id: {
80
+ type: "string",
81
+ description:
82
+ "Global creator ID (UUID), ISNI, IPI number, or MusicBrainz ID",
83
+ },
84
+ },
85
+ required: ["id"],
86
+ },
87
+ },
88
+ executor: this.lookupPersonFull.bind(this),
89
+ },
90
+ ];
91
+ }
92
+
93
+ private async listPeople(args: Record<string, any>): Promise<any> {
94
+ const page = Math.max(args.page || 1, 1);
95
+ const limit = 20;
96
+ const offset = (page - 1) * limit;
97
+
98
+ const params: Record<string, string> = {
99
+ limit: String(limit),
100
+ offset: String(offset),
101
+ };
102
+
103
+ if (args.q) params.q = args.q;
104
+ if (args.isni) params.isni = args.isni;
105
+ if (args.ipi) params.ipi = args.ipi;
106
+
107
+ const response: any = await this.client.request("/people", params);
108
+
109
+ return formatStructuredList(response.data || [], "creator", {
110
+ page,
111
+ total: response.pagination?.total || 0,
112
+ total_pages: Math.ceil((response.pagination?.total || 0) / limit),
113
+ });
114
+ }
115
+
116
+ private async lookupPerson(args: Record<string, any>): Promise<any> {
117
+ const response: any = await this.client.request(
118
+ `/people/${encodeURIComponent(args.id)}`,
119
+ );
120
+
121
+ return formatAsText(response.data);
122
+ }
123
+
124
+ private async lookupPersonFull(args: Record<string, any>): Promise<any> {
125
+ const response: any = await this.client.request(
126
+ `/people/${encodeURIComponent(args.id)}`,
127
+ );
128
+
129
+ const data = response.data;
130
+ if (!data) {
131
+ return {
132
+ content: [{ type: "text", text: "Person not found." }],
133
+ structuredContent: {},
134
+ };
135
+ }
136
+
137
+ const lines: string[] = [];
138
+
139
+ // Name heading
140
+ const name = data.name || data.full_name || data.display_name || "Unknown";
141
+ lines.push(`## ${name}`);
142
+ lines.push("");
143
+
144
+ // Score / verification
145
+ if (data.score !== undefined && data.score !== null) {
146
+ const tier =
147
+ data.score >= 80
148
+ ? "verified"
149
+ : data.score >= 50
150
+ ? "partial"
151
+ : "incomplete";
152
+ lines.push(`- **Score:** ${data.score}/100 (${tier})`);
153
+ lines.push("");
154
+ }
155
+
156
+ // Identifiers
157
+ const identifiers: Array<{ type: string; value: string }> = [];
158
+ if (data.ipi || data.ipi_number)
159
+ identifiers.push({ type: "IPI", value: data.ipi || data.ipi_number });
160
+ if (data.isni) identifiers.push({ type: "ISNI", value: data.isni });
161
+ if (data.musicbrainz_id)
162
+ identifiers.push({ type: "MusicBrainz", value: data.musicbrainz_id });
163
+ if (data.ipn) identifiers.push({ type: "IPN", value: data.ipn });
164
+
165
+ lines.push("### Identifiers");
166
+ if (identifiers.length > 0) {
167
+ lines.push("| Type | Value |");
168
+ lines.push("| --- | --- |");
169
+ for (const id of identifiers) {
170
+ lines.push(`| ${id.type} | ${id.value} |`);
171
+ }
172
+ } else {
173
+ lines.push("_No identifiers recorded._");
174
+ }
175
+ lines.push("");
176
+
177
+ // Works
178
+ const works: any[] = data.works || data.credited_works || [];
179
+ lines.push(`### Works (${works.length})`);
180
+ if (works.length > 0) {
181
+ lines.push("| Title | Role |");
182
+ lines.push("| --- | --- |");
183
+ for (const w of works) {
184
+ const title = w.title || "—";
185
+ const role = w.role || w.credit_role || "—";
186
+ lines.push(`| ${title} | ${role} |`);
187
+ }
188
+ } else {
189
+ lines.push("_No works recorded._");
190
+ }
191
+ lines.push("");
192
+
193
+ // Collaborators
194
+ const collaborators: any[] = data.collaborators || [];
195
+ lines.push(`### Collaborators (${collaborators.length})`);
196
+ if (collaborators.length > 0) {
197
+ lines.push("| Name |");
198
+ lines.push("| --- |");
199
+ for (const c of collaborators) {
200
+ const colName = c.name || c.full_name || c.display_name || "—";
201
+ lines.push(`| ${colName} |`);
202
+ }
203
+ } else {
204
+ lines.push("_No collaborators recorded._");
205
+ }
206
+ lines.push("");
207
+
208
+ const markdown = lines.join("\n");
209
+
210
+ return {
211
+ content: [{ type: "text", text: markdown }],
212
+ structuredContent: data as Record<string, unknown>,
213
+ };
214
+ }
215
+ }
@@ -0,0 +1,145 @@
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
+ description:
20
+ "Search recordings across the PICA directory by audio characteristics. " +
21
+ "Find tracks by BPM, key, energy, danceability, duration, and more. " +
22
+ "Only returns recordings from organisations that have opted into the directory. " +
23
+ "Use this for sync licensing searches like 'find upbeat tracks around 120 BPM in a minor key'. " +
24
+ "→ then: directory_lookup_work_full (credits and licensing details for a matched track)",
25
+ inputSchema: {
26
+ type: "object",
27
+ properties: {
28
+ min_bpm: {
29
+ type: "number",
30
+ description: "Minimum BPM (e.g. 100)",
31
+ },
32
+ max_bpm: {
33
+ type: "number",
34
+ description: "Maximum BPM (e.g. 140)",
35
+ },
36
+ key: {
37
+ type: "string",
38
+ description:
39
+ "Musical key (e.g. 'C', 'F#', 'Bb'). Matches the detected key.",
40
+ },
41
+ key_mode: {
42
+ type: "string",
43
+ enum: ["major", "minor"],
44
+ description: "Key mode — major or minor",
45
+ },
46
+ min_energy: {
47
+ type: "number",
48
+ description: "Minimum energy (0-1). Higher = more energetic.",
49
+ },
50
+ max_energy: {
51
+ type: "number",
52
+ description: "Maximum energy (0-1).",
53
+ },
54
+ min_danceability: {
55
+ type: "number",
56
+ description:
57
+ "Minimum danceability (0-1). Higher = more danceable.",
58
+ },
59
+ max_danceability: {
60
+ type: "number",
61
+ description: "Maximum danceability (0-1).",
62
+ },
63
+ min_duration: {
64
+ type: "number",
65
+ description: "Minimum duration in seconds.",
66
+ },
67
+ max_duration: {
68
+ type: "number",
69
+ description: "Maximum duration in seconds.",
70
+ },
71
+ vocals_only: {
72
+ type: "boolean",
73
+ description: "Only return tracks with vocals.",
74
+ },
75
+ instrumental_only: {
76
+ type: "boolean",
77
+ description: "Only return instrumental tracks.",
78
+ },
79
+ mood: {
80
+ type: "string",
81
+ description: "Filter by mood (exact match).",
82
+ },
83
+ limit: {
84
+ type: "number",
85
+ description: "Max results to return (default: 20, max: 100).",
86
+ },
87
+ },
88
+ },
89
+ },
90
+ executor: this.searchRecordings.bind(this),
91
+ },
92
+ ];
93
+ }
94
+
95
+ private async searchRecordings(args: Record<string, any>): Promise<any> {
96
+ const params: Record<string, string> = {};
97
+
98
+ // Map all filters to query params
99
+ const numericKeys = [
100
+ "min_bpm",
101
+ "max_bpm",
102
+ "min_energy",
103
+ "max_energy",
104
+ "min_danceability",
105
+ "max_danceability",
106
+ "min_duration",
107
+ "max_duration",
108
+ "limit",
109
+ ];
110
+ for (const k of numericKeys) {
111
+ if (args[k] != null) params[k] = String(args[k]);
112
+ }
113
+
114
+ if (args.key) params.key = args.key;
115
+ if (args.key_mode) params.key_mode = args.key_mode;
116
+ if (args.mood) params.mood = args.mood;
117
+ if (args.vocals_only) params.vocals_only = "true";
118
+ if (args.instrumental_only) params.instrumental_only = "true";
119
+
120
+ const response: any = await this.client.request("/recordings", params);
121
+
122
+ const recordings = response.data || [];
123
+
124
+ // Format each recording for readability
125
+ const formatted = recordings.map((r: any) => {
126
+ const parts: string[] = [`${r.title} (${r.isrc})`];
127
+ if (r.artist_name) parts.push(`Artist: ${r.artist_name}`);
128
+ if (r.bpm) parts.push(`BPM: ${Math.round(r.bpm)}`);
129
+ if (r.key)
130
+ parts.push(`Key: ${r.key}${r.key_mode ? ` ${r.key_mode}` : ""}`);
131
+ if (r.energy != null) parts.push(`Energy: ${r.energy}`);
132
+ if (r.duration_seconds)
133
+ parts.push(`Duration: ${Math.round(r.duration_seconds)}s`);
134
+ if (r.has_vocals != null)
135
+ parts.push(r.has_vocals ? "Vocals: yes" : "Instrumental");
136
+ if (r.owner_org_name) parts.push(`Owner: ${r.owner_org_name}`);
137
+ if (r.work_title) parts.push(`Work: ${r.work_title}`);
138
+ return { ...r, _summary: parts.join(" | ") };
139
+ });
140
+
141
+ return formatStructuredList(formatted, "recording", {
142
+ filters: args,
143
+ });
144
+ }
145
+ }
@@ -0,0 +1,63 @@
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 DirectorySearchTools {
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",
19
+ description:
20
+ "Search across all verified works and creators in the PICA public directory. " +
21
+ "Returns mixed results sorted by relevance. Use this for broad searches; " +
22
+ "use list_works or list_people for filtered browsing. " +
23
+ "→ then: directory_lookup_work_full (work details), directory_lookup_person_full (creator details)",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ query: {
28
+ type: "string",
29
+ description:
30
+ "Search query — matches work titles, ISWCs, creator names, ISNIs",
31
+ },
32
+ type: {
33
+ type: "string",
34
+ enum: ["works", "people"],
35
+ description: "Limit results to a specific entity type",
36
+ },
37
+ },
38
+ required: ["query"],
39
+ },
40
+ },
41
+ executor: this.search.bind(this),
42
+ },
43
+ ];
44
+ }
45
+
46
+ private async search(args: Record<string, any>): Promise<any> {
47
+ const params: Record<string, string> = {
48
+ q: args.query,
49
+ limit: "20",
50
+ offset: "0",
51
+ };
52
+
53
+ if (args.type) params.type = args.type;
54
+
55
+ const response: any = await this.client.request("/search", params);
56
+
57
+ return formatStructuredList(response.data || [], "result", {
58
+ query: args.query,
59
+ type: args.type || "all",
60
+ total: response.pagination?.total || 0,
61
+ });
62
+ }
63
+ }
@@ -0,0 +1,273 @@
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 { formatAsText, formatStructuredList } from "../utils/formatting.js";
6
+
7
+ export class DirectoryWorksTools {
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_works",
19
+ description:
20
+ "Browse and filter verified musical works in the PICA public directory. " +
21
+ "Search by title, ISWC, publisher, or label. Returns paginated results. " +
22
+ "→ then: directory_lookup_work_full (full details with credits and audio)",
23
+ inputSchema: {
24
+ type: "object",
25
+ properties: {
26
+ q: {
27
+ type: "string",
28
+ description: "Text search across work titles and identifiers",
29
+ },
30
+ iswc: {
31
+ type: "string",
32
+ description: "Filter by ISWC (e.g. T-123.456.789-0)",
33
+ },
34
+ publisher: {
35
+ type: "string",
36
+ description: "Filter by publisher name (partial match)",
37
+ },
38
+ label: {
39
+ type: "string",
40
+ description: "Filter by record label (partial match)",
41
+ },
42
+ page: {
43
+ type: "number",
44
+ description: "Page number (default: 1, 20 results per page)",
45
+ },
46
+ },
47
+ },
48
+ },
49
+ executor: this.listWorks.bind(this),
50
+ },
51
+ {
52
+ definition: {
53
+ name: "directory_lookup_work",
54
+ description:
55
+ "Get full details of a single work by ISWC or work ID. " +
56
+ "Returns credits, recordings, attestation status, provenance, DSP links, and carbon badge. " +
57
+ "→ then: directory_lookup_work_full (agent-optimised markdown), directory_lookup_person_full (inspect a credited writer)",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: {
61
+ identifier: {
62
+ type: "string",
63
+ description: "ISWC (e.g. T-123.456.789-0) or work UUID",
64
+ },
65
+ },
66
+ required: ["identifier"],
67
+ },
68
+ },
69
+ executor: this.lookupWork.bind(this),
70
+ },
71
+ {
72
+ definition: {
73
+ name: "directory_lookup_work_full",
74
+ description:
75
+ "Get complete details about a work in the public directory — credits with IPI numbers and attestation status, " +
76
+ "recordings with ISRCs, audio analysis (BPM, key, energy), DSP links (Spotify, Apple Music), " +
77
+ "AI provenance, and registration score. Returns a structured markdown summary optimised for agent reasoning. " +
78
+ "→ then: directory_lookup_person_full (research a credited writer), directory_search_recordings (find similar tracks by audio)",
79
+ inputSchema: {
80
+ type: "object",
81
+ properties: {
82
+ identifier: {
83
+ type: "string",
84
+ description: "ISWC (e.g. T-123.456.789-0) or work UUID",
85
+ },
86
+ },
87
+ required: ["identifier"],
88
+ },
89
+ },
90
+ executor: this.lookupWorkFull.bind(this),
91
+ },
92
+ {
93
+ definition: {
94
+ name: "directory_lookup_isrc",
95
+ description:
96
+ "Find musical work(s) associated with an ISRC code. " +
97
+ "ISRC identifies a specific recording; this returns the underlying work(s). " +
98
+ "→ then: directory_lookup_work_full (full details on matched work)",
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: {
102
+ isrc: {
103
+ type: "string",
104
+ description: "ISRC code (e.g. USABC1234567)",
105
+ },
106
+ },
107
+ required: ["isrc"],
108
+ },
109
+ },
110
+ executor: this.lookupIsrc.bind(this),
111
+ },
112
+ ];
113
+ }
114
+
115
+ private async listWorks(args: Record<string, any>): Promise<any> {
116
+ const page = Math.max(args.page || 1, 1);
117
+ const limit = 20;
118
+ const offset = (page - 1) * limit;
119
+
120
+ const params: Record<string, string> = {
121
+ limit: String(limit),
122
+ offset: String(offset),
123
+ };
124
+
125
+ if (args.q) params.q = args.q;
126
+ if (args.iswc) params.iswc = args.iswc;
127
+ if (args.publisher) params.publisher = args.publisher;
128
+ if (args.label) params.label = args.label;
129
+
130
+ const response: any = await this.client.request("/works", params);
131
+
132
+ return formatStructuredList(response.data || [], "work", {
133
+ page,
134
+ total: response.pagination?.total || 0,
135
+ total_pages: Math.ceil((response.pagination?.total || 0) / limit),
136
+ });
137
+ }
138
+
139
+ private async lookupWork(args: Record<string, any>): Promise<any> {
140
+ const response: any = await this.client.request(
141
+ `/works/${encodeURIComponent(args.identifier)}`,
142
+ );
143
+
144
+ return formatAsText(response.data);
145
+ }
146
+
147
+ private async lookupWorkFull(args: Record<string, any>): Promise<any> {
148
+ const response: any = await this.client.request(
149
+ `/works/${encodeURIComponent(args.identifier)}`,
150
+ );
151
+
152
+ const data = response.data;
153
+ if (!data) {
154
+ return {
155
+ content: [{ type: "text", text: "Work not found." }],
156
+ structuredContent: {},
157
+ };
158
+ }
159
+
160
+ const lines: string[] = [];
161
+
162
+ // Title + artist
163
+ const artistName = data.artist_name || data.primary_artist || "";
164
+ lines.push(`## ${data.title}${artistName ? ` by ${artistName}` : ""}`);
165
+ lines.push("");
166
+
167
+ // Key metadata line
168
+ const metaParts: string[] = [];
169
+ if (data.iswc) metaParts.push(`**ISWC:** ${data.iswc}`);
170
+ if (data.organisation_name)
171
+ metaParts.push(`**Organisation:** ${data.organisation_name}`);
172
+ if (metaParts.length > 0) lines.push(metaParts.join(" | "));
173
+
174
+ // Score
175
+ if (data.score !== undefined && data.score !== null) {
176
+ const tier =
177
+ data.score >= 80
178
+ ? "verified"
179
+ : data.score >= 50
180
+ ? "partial"
181
+ : "incomplete";
182
+ lines.push(`- **Score:** ${data.score}/100 (${tier})`);
183
+ }
184
+
185
+ lines.push("");
186
+
187
+ // Credits
188
+ const credits: any[] = data.credits || [];
189
+ lines.push(`### Credits (${credits.length})`);
190
+ if (credits.length > 0) {
191
+ lines.push("| Role | Name | IPI | Attestation |");
192
+ lines.push("| --- | --- | --- | --- |");
193
+ for (const c of credits) {
194
+ const role = c.role || c.credit_role || "—";
195
+ const name = c.name || c.person_name || "—";
196
+ const ipi = c.ipi || c.ipi_number || "—";
197
+ const attestation = c.attestation_status || c.attestation || "—";
198
+ lines.push(`| ${role} | ${name} | ${ipi} | ${attestation} |`);
199
+ }
200
+ } else {
201
+ lines.push("_No credits recorded._");
202
+ }
203
+ lines.push("");
204
+
205
+ // Recordings
206
+ const recordings: any[] = data.recordings || [];
207
+ lines.push(`### Recordings (${recordings.length})`);
208
+ if (recordings.length > 0) {
209
+ lines.push("| ISRC | Type |");
210
+ lines.push("| --- | --- |");
211
+ for (const r of recordings) {
212
+ const isrc = r.isrc || "—";
213
+ const type = r.recording_type || r.type || "—";
214
+ lines.push(`| ${isrc} | ${type} |`);
215
+ }
216
+ } else {
217
+ lines.push("_No recordings linked._");
218
+ }
219
+ lines.push("");
220
+
221
+ // Audio analysis
222
+ const audio = data.audio_analysis || data.audio || null;
223
+ if (audio) {
224
+ lines.push("### Audio");
225
+ const audioParts: string[] = [];
226
+ if (audio.bpm !== undefined && audio.bpm !== null)
227
+ audioParts.push(`**BPM:** ${audio.bpm}`);
228
+ if (audio.key) audioParts.push(`**Key:** ${audio.key}`);
229
+ if (audio.energy !== undefined && audio.energy !== null)
230
+ audioParts.push(`**Energy:** ${audio.energy}`);
231
+ if (audioParts.length > 0) {
232
+ lines.push(`- ${audioParts.join(" | ")}`);
233
+ }
234
+ lines.push("");
235
+ }
236
+
237
+ // DSP links
238
+ const dsp = data.dsp_links || data.streaming_links || null;
239
+ if (dsp && typeof dsp === "object" && Object.keys(dsp).length > 0) {
240
+ lines.push("### DSP Links");
241
+ if (dsp.spotify) lines.push(`- Spotify: ${dsp.spotify}`);
242
+ if (dsp.apple_music) lines.push(`- Apple Music: ${dsp.apple_music}`);
243
+ if (dsp.youtube) lines.push(`- YouTube: ${dsp.youtube}`);
244
+ // catch any other keys
245
+ for (const [key, val] of Object.entries(dsp)) {
246
+ if (!["spotify", "apple_music", "youtube"].includes(key) && val) {
247
+ lines.push(`- ${key}: ${val}`);
248
+ }
249
+ }
250
+ lines.push("");
251
+ }
252
+
253
+ const markdown = lines.join("\n");
254
+
255
+ return {
256
+ content: [{ type: "text", text: markdown }],
257
+ structuredContent: data as Record<string, unknown>,
258
+ };
259
+ }
260
+
261
+ private async lookupIsrc(args: Record<string, any>): Promise<any> {
262
+ const response: any = await this.client.request("/works", {
263
+ isrc: args.isrc,
264
+ limit: "100",
265
+ offset: "0",
266
+ });
267
+
268
+ return formatStructuredList(response.data || [], "work", {
269
+ isrc: args.isrc,
270
+ total: response.pagination?.total || 0,
271
+ });
272
+ }
273
+ }