setlist-mcp 0.3.0 → 0.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/SKILL.md +6 -3
- package/dist/augment.js +21 -0
- package/dist/bundle.js +214 -9
- package/dist/client.js +11 -2
- package/dist/index.js +2 -0
- package/dist/tools/resolve.js +139 -0
- package/dist/tools/setlists.js +13 -5
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for setlist.fm — concert setlists, artists, venues, and tours via the setlist.fm API",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.5.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "setlist.fm",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "MCP server for setlist.fm — search concert setlists, artists, venues, and tours via natural language",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.5.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/SKILL.md
CHANGED
|
@@ -72,7 +72,7 @@ All tools are read-only and prefixed `setlist_`.
|
|
|
72
72
|
- **`setlist_get_artist_setlists`** — an artist's setlists (most recent first), by `mbid`, paginated via `p`.
|
|
73
73
|
|
|
74
74
|
### Setlists
|
|
75
|
-
- **`setlist_search_setlists`** — search by any mix of artist, venue, city, country, tour, `date` (
|
|
75
|
+
- **`setlist_search_setlists`** — search by any mix of artist, venue, city, country, tour, `date` (ISO yyyy-MM-dd), or `year`.
|
|
76
76
|
- **`setlist_get_setlist`** — a setlist (with full song list) by `setlistId`.
|
|
77
77
|
- **`setlist_get_setlist_version`** — a specific historical version by `versionId`.
|
|
78
78
|
|
|
@@ -98,7 +98,7 @@ All tools are read-only and prefixed `setlist_`.
|
|
|
98
98
|
|
|
99
99
|
- **"What did Radiohead play at their last show?"** → `setlist_search_artists` (Radiohead → mbid) → `setlist_get_artist_setlists` (latest) → `setlist_get_setlist` for the song list.
|
|
100
100
|
- **"Setlists at Red Rocks in 2023"** → `setlist_search_venues` (Red Rocks → venueId) → `setlist_search_setlists` with `venueId` + `year: 2023`.
|
|
101
|
-
- **"Phish on 2023-08-07"** → `setlist_search_setlists` with `artistName: "Phish"`, `date: "
|
|
101
|
+
- **"Phish on 2023-08-07"** → `setlist_search_setlists` with `artistName: "Phish"`, `date: "2023-08-07"`.
|
|
102
102
|
|
|
103
103
|
## Attribution & API terms
|
|
104
104
|
|
|
@@ -116,7 +116,10 @@ setlist.fm's [API terms](https://www.setlist.fm/help/api-terms) bind anyone usin
|
|
|
116
116
|
|
|
117
117
|
## Notes
|
|
118
118
|
|
|
119
|
+
- **Stub setlists:** every setlist result carries `songCount` / `setCount` / `hasSongs`. A page can exist with no songs logged (`hasSongs: false`) — skip those without a second `get_setlist` call.
|
|
120
|
+
- **Disambiguate by location:** `artistName` + `date` can return shows in multiple cities. Add `cityName`/`cityId` or `venueName`/`venueId` to pin the right one (e.g. TSO on a date plays both Charlotte and Orlando).
|
|
121
|
+
- **All performers at a venue/festival on a day:** call `setlist_search_setlists` with `venueName` (or `venueId`) + `date` and **no** artist.
|
|
119
122
|
- IDs chain: `search_*` tools return the `mbid` / `setlistId` / `venueId` / `geoId` you feed into the `get_*` tools.
|
|
120
|
-
-
|
|
123
|
+
- **All dates are ISO `yyyy-MM-dd`** — both the `date`/`lastUpdated` inputs and every `eventDate` in the output. (The server translates to/from setlist.fm's native `dd-MM-yyyy` internally.)
|
|
121
124
|
- Results are paginated; pass `p` (1-based) to page through large result sets.
|
|
122
125
|
- setlist.fm rate-limits the standard tier (~2 req/sec); a 429 is retried once.
|
package/dist/augment.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function augmentSetlists(value) {
|
|
2
|
+
if (Array.isArray(value)) {
|
|
3
|
+
for (const item of value)
|
|
4
|
+
augmentSetlists(item);
|
|
5
|
+
}
|
|
6
|
+
else if (value !== null && typeof value === 'object') {
|
|
7
|
+
const obj = value;
|
|
8
|
+
const sets = obj.sets;
|
|
9
|
+
if (sets !== null && typeof sets === 'object') {
|
|
10
|
+
const raw = sets.set;
|
|
11
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
12
|
+
const songCount = list.reduce((n, s) => n + (Array.isArray(s?.song) ? s.song.length : 0), 0);
|
|
13
|
+
obj.songCount = songCount;
|
|
14
|
+
obj.setCount = list.length;
|
|
15
|
+
obj.hasSongs = songCount > 0;
|
|
16
|
+
}
|
|
17
|
+
for (const key of Object.keys(obj))
|
|
18
|
+
augmentSetlists(obj[key]);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
package/dist/bundle.js
CHANGED
|
@@ -31001,6 +31001,22 @@ function textResult(data) {
|
|
|
31001
31001
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
|
|
31002
31002
|
};
|
|
31003
31003
|
}
|
|
31004
|
+
function deepMapStringField(value, field, map2) {
|
|
31005
|
+
if (Array.isArray(value)) {
|
|
31006
|
+
for (const item of value)
|
|
31007
|
+
deepMapStringField(item, field, map2);
|
|
31008
|
+
} else if (value !== null && typeof value === "object") {
|
|
31009
|
+
const obj = value;
|
|
31010
|
+
for (const key of Object.keys(obj)) {
|
|
31011
|
+
const v = obj[key];
|
|
31012
|
+
if (key === field && typeof v === "string")
|
|
31013
|
+
obj[key] = map2(v);
|
|
31014
|
+
else
|
|
31015
|
+
deepMapStringField(v, field, map2);
|
|
31016
|
+
}
|
|
31017
|
+
}
|
|
31018
|
+
return value;
|
|
31019
|
+
}
|
|
31004
31020
|
|
|
31005
31021
|
// node_modules/@chrischall/mcp-utils/dist/errors/index.js
|
|
31006
31022
|
var DEFAULT_ERROR_MESSAGE_MAX = 500;
|
|
@@ -31071,6 +31087,15 @@ var RateLimitedError = class extends Error {
|
|
|
31071
31087
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
31072
31088
|
}
|
|
31073
31089
|
};
|
|
31090
|
+
var RequestTimeoutError = class extends Error {
|
|
31091
|
+
timeoutMs;
|
|
31092
|
+
constructor(service, timeoutMs) {
|
|
31093
|
+
super(`Request to ${service} timed out after ${timeoutMs}ms.`);
|
|
31094
|
+
this.name = "RequestTimeoutError";
|
|
31095
|
+
this.timeoutMs = timeoutMs;
|
|
31096
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
31097
|
+
}
|
|
31098
|
+
};
|
|
31074
31099
|
function hostOf(baseUrl) {
|
|
31075
31100
|
try {
|
|
31076
31101
|
return new URL(baseUrl).host;
|
|
@@ -31086,6 +31111,18 @@ function createApiClient(opts) {
|
|
|
31086
31111
|
const sleep = opts.sleep ?? defaultSleep;
|
|
31087
31112
|
const unauthorized = () => opts.onUnauthorized ? opts.onUnauthorized() : new UnauthorizedError(service);
|
|
31088
31113
|
const rateLimited = () => opts.onRateLimited ? opts.onRateLimited() : new RateLimitedError(service);
|
|
31114
|
+
const timeoutMs = opts.timeout;
|
|
31115
|
+
function withTimeout(run) {
|
|
31116
|
+
if (timeoutMs == null || timeoutMs <= 0)
|
|
31117
|
+
return run(void 0);
|
|
31118
|
+
const controller = new AbortController();
|
|
31119
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
31120
|
+
return run(controller.signal).catch((err) => {
|
|
31121
|
+
if (err instanceof Error && err.name === "AbortError")
|
|
31122
|
+
throw new RequestTimeoutError(service, timeoutMs);
|
|
31123
|
+
throw err;
|
|
31124
|
+
}).finally(() => clearTimeout(timer));
|
|
31125
|
+
}
|
|
31089
31126
|
async function send(method, path, opt) {
|
|
31090
31127
|
const isMultipart = opt.formData !== void 0;
|
|
31091
31128
|
const hasJsonBody = !isMultipart && opt.body !== void 0;
|
|
@@ -31093,7 +31130,7 @@ function createApiClient(opts) {
|
|
|
31093
31130
|
const query = opt.query ? buildQueryString(opt.query) : "";
|
|
31094
31131
|
const url2 = `${base}${path}${query}`;
|
|
31095
31132
|
const bodyInit = reqBody !== void 0 ? { body: reqBody } : {};
|
|
31096
|
-
const fetchWith = (token) => doFetch(url2, {
|
|
31133
|
+
const fetchWith = (token, signal) => doFetch(url2, {
|
|
31097
31134
|
method,
|
|
31098
31135
|
headers: {
|
|
31099
31136
|
Accept: "application/json",
|
|
@@ -31102,9 +31139,10 @@ function createApiClient(opts) {
|
|
|
31102
31139
|
...token ? { Authorization: `Bearer ${token}` } : {},
|
|
31103
31140
|
...opt.headers
|
|
31104
31141
|
},
|
|
31142
|
+
...signal ? { signal } : {},
|
|
31105
31143
|
...bodyInit
|
|
31106
31144
|
});
|
|
31107
|
-
const once =
|
|
31145
|
+
const once = () => withTimeout((signal) => opts.tokenManager ? opts.tokenManager.withAuth((token) => fetchWith(token, signal)) : (async () => fetchWith(await opts.getToken?.() || void 0, signal))());
|
|
31108
31146
|
let attempt = 0;
|
|
31109
31147
|
for (; ; ) {
|
|
31110
31148
|
const res = await once();
|
|
@@ -31171,6 +31209,29 @@ function formatApiError(status, method, path, errorText, opts = {}) {
|
|
|
31171
31209
|
return safe.length > 0 ? `${head}: ${safe}` : head;
|
|
31172
31210
|
}
|
|
31173
31211
|
|
|
31212
|
+
// node_modules/@chrischall/mcp-utils/dist/dates/index.js
|
|
31213
|
+
var ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
31214
|
+
var DMY_DATE = /^(\d{2})-(\d{2})-(\d{4})$/;
|
|
31215
|
+
var ISO_DATETIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/;
|
|
31216
|
+
function isoToDmy(date5) {
|
|
31217
|
+
const m = ISO_DATE.exec(date5.trim());
|
|
31218
|
+
return m ? `${m[3]}-${m[2]}-${m[1]}` : date5.trim();
|
|
31219
|
+
}
|
|
31220
|
+
function dmyToIso(date5) {
|
|
31221
|
+
const m = DMY_DATE.exec(date5.trim());
|
|
31222
|
+
return m ? `${m[3]}-${m[2]}-${m[1]}` : date5.trim();
|
|
31223
|
+
}
|
|
31224
|
+
function isoToCompactTimestamp(value) {
|
|
31225
|
+
const v = value.trim();
|
|
31226
|
+
const d = ISO_DATE.exec(v);
|
|
31227
|
+
if (d)
|
|
31228
|
+
return `${d[1]}${d[2]}${d[3]}000000`;
|
|
31229
|
+
const dt = ISO_DATETIME.exec(v);
|
|
31230
|
+
if (dt)
|
|
31231
|
+
return `${dt[1]}${dt[2]}${dt[3]}${dt[4]}${dt[5]}${dt[6] ?? "00"}`;
|
|
31232
|
+
return v;
|
|
31233
|
+
}
|
|
31234
|
+
|
|
31174
31235
|
// node_modules/@chrischall/mcp-utils/dist/zod/index.js
|
|
31175
31236
|
var PositiveInt = external_exports.number().int().positive();
|
|
31176
31237
|
var NonNegInt = external_exports.number().int().nonnegative();
|
|
@@ -31197,15 +31258,38 @@ function toolAnnotations(opts = {}) {
|
|
|
31197
31258
|
}
|
|
31198
31259
|
|
|
31199
31260
|
// src/version.ts
|
|
31200
|
-
var VERSION = "0.
|
|
31261
|
+
var VERSION = "0.5.0";
|
|
31201
31262
|
|
|
31202
31263
|
// src/client.ts
|
|
31203
31264
|
import { dirname, join } from "path";
|
|
31204
31265
|
import { fileURLToPath } from "url";
|
|
31266
|
+
|
|
31267
|
+
// src/augment.ts
|
|
31268
|
+
function augmentSetlists(value) {
|
|
31269
|
+
if (Array.isArray(value)) {
|
|
31270
|
+
for (const item of value) augmentSetlists(item);
|
|
31271
|
+
} else if (value !== null && typeof value === "object") {
|
|
31272
|
+
const obj = value;
|
|
31273
|
+
const sets = obj.sets;
|
|
31274
|
+
if (sets !== null && typeof sets === "object") {
|
|
31275
|
+
const raw = sets.set;
|
|
31276
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
31277
|
+
const songCount = list.reduce((n, s) => n + (Array.isArray(s?.song) ? s.song.length : 0), 0);
|
|
31278
|
+
obj.songCount = songCount;
|
|
31279
|
+
obj.setCount = list.length;
|
|
31280
|
+
obj.hasSongs = songCount > 0;
|
|
31281
|
+
}
|
|
31282
|
+
for (const key of Object.keys(obj)) augmentSetlists(obj[key]);
|
|
31283
|
+
}
|
|
31284
|
+
return value;
|
|
31285
|
+
}
|
|
31286
|
+
|
|
31287
|
+
// src/client.ts
|
|
31205
31288
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31206
31289
|
await loadDotenvSafely({ path: join(__dirname, "..", ".env"), override: false });
|
|
31207
31290
|
var BASE_URL = "https://api.setlist.fm/rest";
|
|
31208
31291
|
var SERVICE_NAME = "setlist.fm";
|
|
31292
|
+
var REQUEST_TIMEOUT_MS = 15e3;
|
|
31209
31293
|
var SetlistClient = class {
|
|
31210
31294
|
apiKey;
|
|
31211
31295
|
configError;
|
|
@@ -31229,7 +31313,8 @@ var SetlistClient = class {
|
|
|
31229
31313
|
baseUrl: BASE_URL,
|
|
31230
31314
|
serviceName: SERVICE_NAME,
|
|
31231
31315
|
retry: { count: 1, delayMs: 2e3 },
|
|
31232
|
-
baseHeaders: lang ? { "Accept-Language": lang } : void 0
|
|
31316
|
+
baseHeaders: lang ? { "Accept-Language": lang } : void 0,
|
|
31317
|
+
timeout: REQUEST_TIMEOUT_MS
|
|
31233
31318
|
});
|
|
31234
31319
|
}
|
|
31235
31320
|
requireKey() {
|
|
@@ -31243,11 +31328,12 @@ var SetlistClient = class {
|
|
|
31243
31328
|
*/
|
|
31244
31329
|
async request(method, path, opts = {}) {
|
|
31245
31330
|
const apiKey = this.requireKey();
|
|
31246
|
-
|
|
31331
|
+
const data = await this.api.fetchJson(method, path, {
|
|
31247
31332
|
headers: { "x-api-key": apiKey },
|
|
31248
31333
|
...opts.query !== void 0 ? { query: opts.query } : {},
|
|
31249
31334
|
...opts.body !== void 0 ? { body: opts.body } : {}
|
|
31250
31335
|
});
|
|
31336
|
+
return augmentSetlists(deepMapStringField(data, "eventDate", dmyToIso));
|
|
31251
31337
|
}
|
|
31252
31338
|
};
|
|
31253
31339
|
var client = new SetlistClient();
|
|
@@ -31319,7 +31405,7 @@ function registerSetlistTools(server) {
|
|
|
31319
31405
|
server.registerTool(
|
|
31320
31406
|
"setlist_search_setlists",
|
|
31321
31407
|
{
|
|
31322
|
-
description: "Search setlist.fm for concert setlists. Filter by any combination of artist, venue, city, country, tour, date, or year.
|
|
31408
|
+
description: "Search setlist.fm for concert setlists. Filter by any combination of artist, venue, city, country, tour, date, or year (provide at least one). Combine filters to disambiguate \u2014 artistName + date can span multiple cities, so add cityName/cityId or venueName/venueId to pin the exact show. Omit the artist and pass venueName/venueId + date to list EVERY performer at a venue or festival that day. Every result includes songCount, setCount, and hasSongs, so you can skip empty 'stub' setlists (hasSongs: false) without a second fetch." + ATTRIBUTION_NOTE,
|
|
31323
31409
|
annotations: { readOnlyHint: true },
|
|
31324
31410
|
inputSchema: {
|
|
31325
31411
|
artistName: external_exports.string().optional().describe("Artist name"),
|
|
@@ -31332,14 +31418,17 @@ function registerSetlistTools(server) {
|
|
|
31332
31418
|
stateCode: external_exports.string().optional().describe("State code"),
|
|
31333
31419
|
countryCode: external_exports.string().optional().describe("Country code (ISO 3166-1 alpha-2)"),
|
|
31334
31420
|
tourName: external_exports.string().optional().describe("Tour name"),
|
|
31335
|
-
date: external_exports.string().optional().describe("Event date
|
|
31421
|
+
date: external_exports.string().optional().describe("Event date, ISO yyyy-MM-dd (e.g. 2025-08-28)"),
|
|
31336
31422
|
year: external_exports.number().int().optional().describe("Event year"),
|
|
31337
|
-
lastUpdated: external_exports.string().optional().describe("Only setlists updated after this UTC time
|
|
31423
|
+
lastUpdated: external_exports.string().optional().describe("Only setlists updated on/after this UTC time, ISO yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss"),
|
|
31338
31424
|
p: page2
|
|
31339
31425
|
}
|
|
31340
31426
|
},
|
|
31341
31427
|
async (args) => {
|
|
31342
|
-
const
|
|
31428
|
+
const query = { ...args };
|
|
31429
|
+
if (args.date) query.date = isoToDmy(args.date);
|
|
31430
|
+
if (args.lastUpdated) query.lastUpdated = isoToCompactTimestamp(args.lastUpdated);
|
|
31431
|
+
const data = await client.request("GET", "/1.0/search/setlists", { query });
|
|
31343
31432
|
return textResult(data);
|
|
31344
31433
|
}
|
|
31345
31434
|
);
|
|
@@ -31539,6 +31628,121 @@ function registerUserTools(server) {
|
|
|
31539
31628
|
);
|
|
31540
31629
|
}
|
|
31541
31630
|
|
|
31631
|
+
// src/tools/resolve.ts
|
|
31632
|
+
var MAX_BATCH = 24;
|
|
31633
|
+
function songCountOf(s) {
|
|
31634
|
+
const sets = Array.isArray(s.sets?.set) ? s.sets.set : [];
|
|
31635
|
+
return sets.reduce((n, set2) => n + (Array.isArray(set2?.song) ? set2.song.length : 0), 0);
|
|
31636
|
+
}
|
|
31637
|
+
function normalizeArtist(name) {
|
|
31638
|
+
return name.replace(/["'’‘”“]/g, "").replace(/\s*[+&]\s*/g, " and ").replace(/\./g, "").replace(/\s+/g, " ").trim();
|
|
31639
|
+
}
|
|
31640
|
+
async function emptyOn404(run, fallback) {
|
|
31641
|
+
try {
|
|
31642
|
+
return await run();
|
|
31643
|
+
} catch (err) {
|
|
31644
|
+
if (/\b404\b/.test(messageOf(err))) return fallback;
|
|
31645
|
+
throw err;
|
|
31646
|
+
}
|
|
31647
|
+
}
|
|
31648
|
+
async function searchSetlists(query) {
|
|
31649
|
+
return emptyOn404(async () => {
|
|
31650
|
+
const data = await client.request("GET", "/1.0/search/setlists", {
|
|
31651
|
+
query
|
|
31652
|
+
});
|
|
31653
|
+
return data?.setlist ?? [];
|
|
31654
|
+
}, []);
|
|
31655
|
+
}
|
|
31656
|
+
async function topArtistMbid(artistName) {
|
|
31657
|
+
return emptyOn404(async () => {
|
|
31658
|
+
const data = await client.request("GET", "/1.0/search/artists", {
|
|
31659
|
+
query: { artistName, sort: "relevance" }
|
|
31660
|
+
});
|
|
31661
|
+
return data?.artist?.[0]?.mbid;
|
|
31662
|
+
}, void 0);
|
|
31663
|
+
}
|
|
31664
|
+
function score(s, city, venue) {
|
|
31665
|
+
let sc = 0;
|
|
31666
|
+
const vn = s.venue?.name?.toLowerCase() ?? "";
|
|
31667
|
+
const cn = s.venue?.city?.name?.toLowerCase() ?? "";
|
|
31668
|
+
if (venue && vn.includes(venue.toLowerCase())) sc += 4;
|
|
31669
|
+
if (city && cn.includes(city.toLowerCase())) sc += 2;
|
|
31670
|
+
if (songCountOf(s) > 0) sc += 1;
|
|
31671
|
+
return sc;
|
|
31672
|
+
}
|
|
31673
|
+
function pickBest(list, city, venue) {
|
|
31674
|
+
if (list.length === 0) return void 0;
|
|
31675
|
+
return [...list].sort((a, b) => score(b, city, venue) - score(a, city, venue) || songCountOf(b) - songCountOf(a))[0];
|
|
31676
|
+
}
|
|
31677
|
+
async function resolveOne(c) {
|
|
31678
|
+
const filters = {
|
|
31679
|
+
date: isoToDmy(c.date),
|
|
31680
|
+
...c.city ? { cityName: c.city } : {},
|
|
31681
|
+
...c.venue ? { venueName: c.venue } : {}
|
|
31682
|
+
};
|
|
31683
|
+
let list = await searchSetlists({ artistName: c.artist, ...filters });
|
|
31684
|
+
if (list.length === 0) {
|
|
31685
|
+
const mbid = await topArtistMbid(c.artist);
|
|
31686
|
+
if (mbid) list = await searchSetlists({ artistMbid: mbid, ...filters });
|
|
31687
|
+
}
|
|
31688
|
+
if (list.length === 0) {
|
|
31689
|
+
const norm = normalizeArtist(c.artist);
|
|
31690
|
+
if (norm && norm.toLowerCase() !== c.artist.toLowerCase()) {
|
|
31691
|
+
list = await searchSetlists({ artistName: norm, ...filters });
|
|
31692
|
+
}
|
|
31693
|
+
}
|
|
31694
|
+
const best = pickBest(list, c.city, c.venue);
|
|
31695
|
+
if (!best) return { input: c, match: null, alternatives: 0 };
|
|
31696
|
+
const songCount = songCountOf(best);
|
|
31697
|
+
return {
|
|
31698
|
+
input: c,
|
|
31699
|
+
match: {
|
|
31700
|
+
setlistId: best.id,
|
|
31701
|
+
url: best.url,
|
|
31702
|
+
eventDate: best.eventDate,
|
|
31703
|
+
// already ISO (normalized by client.request)
|
|
31704
|
+
artist: best.artist?.name,
|
|
31705
|
+
venue: best.venue?.name,
|
|
31706
|
+
city: best.venue?.city?.name,
|
|
31707
|
+
tour: best.tour?.name,
|
|
31708
|
+
songCount,
|
|
31709
|
+
hasSongs: songCount > 0
|
|
31710
|
+
},
|
|
31711
|
+
alternatives: list.length - 1
|
|
31712
|
+
};
|
|
31713
|
+
}
|
|
31714
|
+
function registerResolveTools(server) {
|
|
31715
|
+
server.registerTool(
|
|
31716
|
+
"setlist_resolve_concerts",
|
|
31717
|
+
{
|
|
31718
|
+
description: "Resolve many concerts to their setlists in ONE call (instead of 2+ per show). Given up to 24 `{artist, date, city?, venue?}`, returns the best-match setlist for each \u2014 `{setlistId, url, eventDate, artist, venue, city, tour, songCount, hasSongs}` \u2014 plus a `{matched, stubs, unmatched}` summary. For each: searches artist + date (narrowed by your city/venue), and on a miss falls back to a relevance artist lookup (by mbid) and a punctuation-normalized name so format variants still resolve. `hasSongs: false` flags an empty stub page. Processed sequentially to respect setlist.fm's rate limit; chunk lists longer than 24 across calls." + ATTRIBUTION_NOTE,
|
|
31719
|
+
annotations: { readOnlyHint: true },
|
|
31720
|
+
inputSchema: {
|
|
31721
|
+
concerts: external_exports.array(
|
|
31722
|
+
external_exports.object({
|
|
31723
|
+
artist: external_exports.string().describe("Artist name"),
|
|
31724
|
+
date: external_exports.string().describe("Event date, ISO yyyy-MM-dd (e.g. 2025-08-28)"),
|
|
31725
|
+
city: external_exports.string().optional().describe("City to disambiguate multi-city dates (optional)"),
|
|
31726
|
+
venue: external_exports.string().optional().describe("Venue to disambiguate (optional)")
|
|
31727
|
+
})
|
|
31728
|
+
).min(1).max(MAX_BATCH).describe(`Concerts to resolve (1\u2013${MAX_BATCH} per call)`)
|
|
31729
|
+
}
|
|
31730
|
+
},
|
|
31731
|
+
async ({ concerts }) => {
|
|
31732
|
+
const results = [];
|
|
31733
|
+
for (const c of concerts) {
|
|
31734
|
+
results.push(await resolveOne(c));
|
|
31735
|
+
}
|
|
31736
|
+
const matched = results.filter((r) => r.match).length;
|
|
31737
|
+
const stubs = results.filter((r) => r.match && !r.match.hasSongs).length;
|
|
31738
|
+
return textResult({
|
|
31739
|
+
results,
|
|
31740
|
+
summary: { total: results.length, matched, stubs, unmatched: results.length - matched }
|
|
31741
|
+
});
|
|
31742
|
+
}
|
|
31743
|
+
);
|
|
31744
|
+
}
|
|
31745
|
+
|
|
31542
31746
|
// src/tools/utilities.ts
|
|
31543
31747
|
function registerUtilityTools(server) {
|
|
31544
31748
|
server.registerTool(
|
|
@@ -31590,6 +31794,7 @@ await runMcp({
|
|
|
31590
31794
|
registerVenueTools,
|
|
31591
31795
|
registerGeoTools,
|
|
31592
31796
|
registerUserTools,
|
|
31797
|
+
registerResolveTools,
|
|
31593
31798
|
registerUtilityTools
|
|
31594
31799
|
]
|
|
31595
31800
|
});
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, join } from 'path';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
|
-
import { loadDotenvSafely, readEnvVar, createApiClient, } from '@chrischall/mcp-utils';
|
|
3
|
+
import { loadDotenvSafely, readEnvVar, createApiClient, deepMapStringField, dmyToIso, } from '@chrischall/mcp-utils';
|
|
4
|
+
import { augmentSetlists } from './augment.js';
|
|
4
5
|
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. the
|
|
5
6
|
// mcpb bundle). `loadDotenvSafely` swallows a missing dotenv module and never
|
|
6
7
|
// lets .env override a host-provided value.
|
|
@@ -8,6 +9,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
8
9
|
await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
|
|
9
10
|
const BASE_URL = 'https://api.setlist.fm/rest';
|
|
10
11
|
const SERVICE_NAME = 'setlist.fm';
|
|
12
|
+
// Bound every request so a slow/hung upstream fails fast (createApiClient throws
|
|
13
|
+
// RequestTimeoutError) instead of hanging the tool call. setlist.fm normally
|
|
14
|
+
// answers in well under a second.
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
11
16
|
export class SetlistClient {
|
|
12
17
|
apiKey;
|
|
13
18
|
configError;
|
|
@@ -37,6 +42,7 @@ export class SetlistClient {
|
|
|
37
42
|
serviceName: SERVICE_NAME,
|
|
38
43
|
retry: { count: 1, delayMs: 2000 },
|
|
39
44
|
baseHeaders: lang ? { 'Accept-Language': lang } : undefined,
|
|
45
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
40
46
|
});
|
|
41
47
|
}
|
|
42
48
|
requireKey() {
|
|
@@ -51,11 +57,14 @@ export class SetlistClient {
|
|
|
51
57
|
*/
|
|
52
58
|
async request(method, path, opts = {}) {
|
|
53
59
|
const apiKey = this.requireKey();
|
|
54
|
-
|
|
60
|
+
const data = await this.api.fetchJson(method, path, {
|
|
55
61
|
headers: { 'x-api-key': apiKey },
|
|
56
62
|
...(opts.query !== undefined ? { query: opts.query } : {}),
|
|
57
63
|
...(opts.body !== undefined ? { body: opts.body } : {}),
|
|
58
64
|
});
|
|
65
|
+
// Surface every date as ISO yyyy-MM-dd (the API returns eventDate as dd-MM-yyyy),
|
|
66
|
+
// and annotate setlists with songCount/setCount/hasSongs so stubs are visible.
|
|
67
|
+
return augmentSetlists(deepMapStringField(data, 'eventDate', dmyToIso));
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
/**
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { registerSetlistTools } from './tools/setlists.js';
|
|
|
6
6
|
import { registerVenueTools } from './tools/venues.js';
|
|
7
7
|
import { registerGeoTools } from './tools/geo.js';
|
|
8
8
|
import { registerUserTools } from './tools/users.js';
|
|
9
|
+
import { registerResolveTools } from './tools/resolve.js';
|
|
9
10
|
import { registerUtilityTools } from './tools/utilities.js';
|
|
10
11
|
// The setlist.fm client is a module-level singleton (imported by each tool
|
|
11
12
|
// module) that defers its config error to the first request. That preserves the
|
|
@@ -22,6 +23,7 @@ await runMcp({
|
|
|
22
23
|
registerVenueTools,
|
|
23
24
|
registerGeoTools,
|
|
24
25
|
registerUserTools,
|
|
26
|
+
registerResolveTools,
|
|
25
27
|
registerUtilityTools,
|
|
26
28
|
],
|
|
27
29
|
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, isoToDmy, messageOf } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
import { ATTRIBUTION_NOTE } from '../attribution.js';
|
|
5
|
+
const MAX_BATCH = 24;
|
|
6
|
+
function songCountOf(s) {
|
|
7
|
+
const sets = Array.isArray(s.sets?.set) ? s.sets.set : [];
|
|
8
|
+
return sets.reduce((n, set) => n + (Array.isArray(set?.song) ? set.song.length : 0), 0);
|
|
9
|
+
}
|
|
10
|
+
// Loosen punctuation/format variants for a fuzzy retry: drop quotes, turn + / &
|
|
11
|
+
// into "and", drop stray periods, collapse whitespace. (Dan + Shay, "Weird Al"
|
|
12
|
+
// Yankovic, DJ Pee .Wee.)
|
|
13
|
+
function normalizeArtist(name) {
|
|
14
|
+
return name
|
|
15
|
+
.replace(/["'’‘”“]/g, '')
|
|
16
|
+
.replace(/\s*[+&]\s*/g, ' and ')
|
|
17
|
+
.replace(/\./g, '')
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim();
|
|
20
|
+
}
|
|
21
|
+
// A no-match search returns HTTP 404 from setlist.fm — treat that as "empty",
|
|
22
|
+
// not an error; let anything else propagate.
|
|
23
|
+
async function emptyOn404(run, fallback) {
|
|
24
|
+
try {
|
|
25
|
+
return await run();
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (/\b404\b/.test(messageOf(err)))
|
|
29
|
+
return fallback;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function searchSetlists(query) {
|
|
34
|
+
return emptyOn404(async () => {
|
|
35
|
+
const data = await client.request('GET', '/1.0/search/setlists', {
|
|
36
|
+
query,
|
|
37
|
+
});
|
|
38
|
+
return data?.setlist ?? [];
|
|
39
|
+
}, []);
|
|
40
|
+
}
|
|
41
|
+
async function topArtistMbid(artistName) {
|
|
42
|
+
return emptyOn404(async () => {
|
|
43
|
+
const data = await client.request('GET', '/1.0/search/artists', {
|
|
44
|
+
query: { artistName, sort: 'relevance' },
|
|
45
|
+
});
|
|
46
|
+
return data?.artist?.[0]?.mbid;
|
|
47
|
+
}, undefined);
|
|
48
|
+
}
|
|
49
|
+
function score(s, city, venue) {
|
|
50
|
+
let sc = 0;
|
|
51
|
+
const vn = s.venue?.name?.toLowerCase() ?? '';
|
|
52
|
+
const cn = s.venue?.city?.name?.toLowerCase() ?? '';
|
|
53
|
+
if (venue && vn.includes(venue.toLowerCase()))
|
|
54
|
+
sc += 4;
|
|
55
|
+
if (city && cn.includes(city.toLowerCase()))
|
|
56
|
+
sc += 2;
|
|
57
|
+
if (songCountOf(s) > 0)
|
|
58
|
+
sc += 1;
|
|
59
|
+
return sc;
|
|
60
|
+
}
|
|
61
|
+
function pickBest(list, city, venue) {
|
|
62
|
+
if (list.length === 0)
|
|
63
|
+
return undefined;
|
|
64
|
+
// Best location/song score wins; prefer a populated setlist on ties via songCount.
|
|
65
|
+
return [...list].sort((a, b) => score(b, city, venue) - score(a, city, venue) || songCountOf(b) - songCountOf(a))[0];
|
|
66
|
+
}
|
|
67
|
+
async function resolveOne(c) {
|
|
68
|
+
const filters = {
|
|
69
|
+
date: isoToDmy(c.date),
|
|
70
|
+
...(c.city ? { cityName: c.city } : {}),
|
|
71
|
+
...(c.venue ? { venueName: c.venue } : {}),
|
|
72
|
+
};
|
|
73
|
+
let list = await searchSetlists({ artistName: c.artist, ...filters });
|
|
74
|
+
// Fuzzy fallback 1: resolve the artist via the (more forgiving) relevance
|
|
75
|
+
// search, then query by its mbid.
|
|
76
|
+
if (list.length === 0) {
|
|
77
|
+
const mbid = await topArtistMbid(c.artist);
|
|
78
|
+
if (mbid)
|
|
79
|
+
list = await searchSetlists({ artistMbid: mbid, ...filters });
|
|
80
|
+
}
|
|
81
|
+
// Fuzzy fallback 2: a punctuation-normalized name.
|
|
82
|
+
if (list.length === 0) {
|
|
83
|
+
const norm = normalizeArtist(c.artist);
|
|
84
|
+
if (norm && norm.toLowerCase() !== c.artist.toLowerCase()) {
|
|
85
|
+
list = await searchSetlists({ artistName: norm, ...filters });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const best = pickBest(list, c.city, c.venue);
|
|
89
|
+
if (!best)
|
|
90
|
+
return { input: c, match: null, alternatives: 0 };
|
|
91
|
+
const songCount = songCountOf(best);
|
|
92
|
+
return {
|
|
93
|
+
input: c,
|
|
94
|
+
match: {
|
|
95
|
+
setlistId: best.id,
|
|
96
|
+
url: best.url,
|
|
97
|
+
eventDate: best.eventDate, // already ISO (normalized by client.request)
|
|
98
|
+
artist: best.artist?.name,
|
|
99
|
+
venue: best.venue?.name,
|
|
100
|
+
city: best.venue?.city?.name,
|
|
101
|
+
tour: best.tour?.name,
|
|
102
|
+
songCount,
|
|
103
|
+
hasSongs: songCount > 0,
|
|
104
|
+
},
|
|
105
|
+
alternatives: list.length - 1,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function registerResolveTools(server) {
|
|
109
|
+
server.registerTool('setlist_resolve_concerts', {
|
|
110
|
+
description: "Resolve many concerts to their setlists in ONE call (instead of 2+ per show). Given up to 24 `{artist, date, city?, venue?}`, returns the best-match setlist for each — `{setlistId, url, eventDate, artist, venue, city, tour, songCount, hasSongs}` — plus a `{matched, stubs, unmatched}` summary. For each: searches artist + date (narrowed by your city/venue), and on a miss falls back to a relevance artist lookup (by mbid) and a punctuation-normalized name so format variants still resolve. `hasSongs: false` flags an empty stub page. Processed sequentially to respect setlist.fm's rate limit; chunk lists longer than 24 across calls." +
|
|
111
|
+
ATTRIBUTION_NOTE,
|
|
112
|
+
annotations: { readOnlyHint: true },
|
|
113
|
+
inputSchema: {
|
|
114
|
+
concerts: z
|
|
115
|
+
.array(z.object({
|
|
116
|
+
artist: z.string().describe('Artist name'),
|
|
117
|
+
date: z.string().describe('Event date, ISO yyyy-MM-dd (e.g. 2025-08-28)'),
|
|
118
|
+
city: z.string().optional().describe('City to disambiguate multi-city dates (optional)'),
|
|
119
|
+
venue: z.string().optional().describe('Venue to disambiguate (optional)'),
|
|
120
|
+
}))
|
|
121
|
+
.min(1)
|
|
122
|
+
.max(MAX_BATCH)
|
|
123
|
+
.describe(`Concerts to resolve (1–${MAX_BATCH} per call)`),
|
|
124
|
+
},
|
|
125
|
+
}, async ({ concerts }) => {
|
|
126
|
+
const results = [];
|
|
127
|
+
// Sequential on purpose: one request at a time keeps us within setlist.fm's
|
|
128
|
+
// ~2 req/sec limit (the client also retries once on a 429).
|
|
129
|
+
for (const c of concerts) {
|
|
130
|
+
results.push((await resolveOne(c)));
|
|
131
|
+
}
|
|
132
|
+
const matched = results.filter((r) => r.match).length;
|
|
133
|
+
const stubs = results.filter((r) => r.match && !r.match.hasSongs).length;
|
|
134
|
+
return textResult({
|
|
135
|
+
results,
|
|
136
|
+
summary: { total: results.length, matched, stubs, unmatched: results.length - matched },
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
package/dist/tools/setlists.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { textResult } from '@chrischall/mcp-utils';
|
|
2
|
+
import { textResult, isoToDmy, isoToCompactTimestamp } from '@chrischall/mcp-utils';
|
|
3
3
|
import { client } from '../client.js';
|
|
4
4
|
import { ATTRIBUTION_NOTE } from '../attribution.js';
|
|
5
5
|
// How to read a setlist's song data (per setlist.fm's guidelines), so the model
|
|
@@ -13,7 +13,7 @@ const page = z
|
|
|
13
13
|
.describe('Result page number (defaults to 1)');
|
|
14
14
|
export function registerSetlistTools(server) {
|
|
15
15
|
server.registerTool('setlist_search_setlists', {
|
|
16
|
-
description: "Search setlist.fm for concert setlists. Filter by any combination of artist, venue, city, country, tour, date, or year.
|
|
16
|
+
description: "Search setlist.fm for concert setlists. Filter by any combination of artist, venue, city, country, tour, date, or year (provide at least one). Combine filters to disambiguate — artistName + date can span multiple cities, so add cityName/cityId or venueName/venueId to pin the exact show. Omit the artist and pass venueName/venueId + date to list EVERY performer at a venue or festival that day. Every result includes songCount, setCount, and hasSongs, so you can skip empty 'stub' setlists (hasSongs: false) without a second fetch." +
|
|
17
17
|
ATTRIBUTION_NOTE,
|
|
18
18
|
annotations: { readOnlyHint: true },
|
|
19
19
|
inputSchema: {
|
|
@@ -27,16 +27,24 @@ export function registerSetlistTools(server) {
|
|
|
27
27
|
stateCode: z.string().optional().describe('State code'),
|
|
28
28
|
countryCode: z.string().optional().describe('Country code (ISO 3166-1 alpha-2)'),
|
|
29
29
|
tourName: z.string().optional().describe('Tour name'),
|
|
30
|
-
date: z
|
|
30
|
+
date: z
|
|
31
|
+
.string()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('Event date, ISO yyyy-MM-dd (e.g. 2025-08-28)'),
|
|
31
34
|
year: z.number().int().optional().describe('Event year'),
|
|
32
35
|
lastUpdated: z
|
|
33
36
|
.string()
|
|
34
37
|
.optional()
|
|
35
|
-
.describe('Only setlists updated after this UTC time
|
|
38
|
+
.describe('Only setlists updated on/after this UTC time, ISO yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss'),
|
|
36
39
|
p: page,
|
|
37
40
|
},
|
|
38
41
|
}, async (args) => {
|
|
39
|
-
const
|
|
42
|
+
const query = { ...args };
|
|
43
|
+
if (args.date)
|
|
44
|
+
query.date = isoToDmy(args.date);
|
|
45
|
+
if (args.lastUpdated)
|
|
46
|
+
query.lastUpdated = isoToCompactTimestamp(args.lastUpdated);
|
|
47
|
+
const data = await client.request('GET', '/1.0/search/setlists', { query });
|
|
40
48
|
return textResult(data);
|
|
41
49
|
});
|
|
42
50
|
server.registerTool('setlist_get_setlist', {
|
package/dist/version.js
CHANGED
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
// release-please-config.json's `extra-files`), and `versionSyncTest` guards
|
|
4
4
|
// that it stays equal to package.json. Import VERSION wherever the version is
|
|
5
5
|
// needed rather than re-declaring it.
|
|
6
|
-
export const VERSION = '0.
|
|
6
|
+
export const VERSION = '0.5.0'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "setlist-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/setlist-mcp",
|
|
5
5
|
"description": "setlist.fm MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"test:coverage": "vitest run --coverage"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@chrischall/mcp-utils": "^0.
|
|
45
|
+
"@chrischall/mcp-utils": "^0.6.0",
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
47
47
|
"dotenv": "^17.4.0",
|
|
48
48
|
"zod": "^4.4.2"
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/setlist-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.5.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "setlist-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.5.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|