setlist-mcp 0.4.0 → 0.5.1
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 -0
- package/dist/augment.js +21 -0
- package/dist/bundle.js +171 -3
- package/dist/client.js +4 -2
- package/dist/index.js +2 -0
- package/dist/tools/resolve.js +181 -0
- package/dist/tools/setlists.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- 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.1"
|
|
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.1",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/SKILL.md
CHANGED
|
@@ -76,6 +76,9 @@ All tools are read-only and prefixed `setlist_`.
|
|
|
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
|
|
|
79
|
+
### Batch
|
|
80
|
+
- **`setlist_resolve_concerts`** — resolve up to **24** `{artist, date, city?, venue?}` to their best-match setlists in one call (with `songCount`/`hasSongs` + a `{matched, stubs, unmatched, pending}` summary). Calls are paced to setlist.fm's rate limit; if a batch can't finish in time the rest come back `pending: true` — re-call with just those. For more than 24 shows, chunk into batches of ≤24.
|
|
81
|
+
|
|
79
82
|
### Venues
|
|
80
83
|
- **`setlist_search_venues`** — find venues by `name` and/or location.
|
|
81
84
|
- **`setlist_get_venue`** — get a venue by `venueId`.
|
|
@@ -116,6 +119,9 @@ setlist.fm's [API terms](https://www.setlist.fm/help/api-terms) bind anyone usin
|
|
|
116
119
|
|
|
117
120
|
## Notes
|
|
118
121
|
|
|
122
|
+
- **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.
|
|
123
|
+
- **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).
|
|
124
|
+
- **All performers at a venue/festival on a day:** call `setlist_search_setlists` with `venueName` (or `venueId`) + `date` and **no** artist.
|
|
119
125
|
- IDs chain: `search_*` tools return the `mbid` / `setlistId` / `venueId` / `geoId` you feed into the `get_*` tools.
|
|
120
126
|
- **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
127
|
- Results are paginated; pass `p` (1-based) to page through large result sets.
|
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
|
@@ -31258,11 +31258,33 @@ function toolAnnotations(opts = {}) {
|
|
|
31258
31258
|
}
|
|
31259
31259
|
|
|
31260
31260
|
// src/version.ts
|
|
31261
|
-
var VERSION = "0.
|
|
31261
|
+
var VERSION = "0.5.1";
|
|
31262
31262
|
|
|
31263
31263
|
// src/client.ts
|
|
31264
31264
|
import { dirname, join } from "path";
|
|
31265
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
|
|
31266
31288
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31267
31289
|
await loadDotenvSafely({ path: join(__dirname, "..", ".env"), override: false });
|
|
31268
31290
|
var BASE_URL = "https://api.setlist.fm/rest";
|
|
@@ -31311,7 +31333,7 @@ var SetlistClient = class {
|
|
|
31311
31333
|
...opts.query !== void 0 ? { query: opts.query } : {},
|
|
31312
31334
|
...opts.body !== void 0 ? { body: opts.body } : {}
|
|
31313
31335
|
});
|
|
31314
|
-
return deepMapStringField(data, "eventDate", dmyToIso);
|
|
31336
|
+
return augmentSetlists(deepMapStringField(data, "eventDate", dmyToIso));
|
|
31315
31337
|
}
|
|
31316
31338
|
};
|
|
31317
31339
|
var client = new SetlistClient();
|
|
@@ -31383,7 +31405,7 @@ function registerSetlistTools(server) {
|
|
|
31383
31405
|
server.registerTool(
|
|
31384
31406
|
"setlist_search_setlists",
|
|
31385
31407
|
{
|
|
31386
|
-
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,
|
|
31387
31409
|
annotations: { readOnlyHint: true },
|
|
31388
31410
|
inputSchema: {
|
|
31389
31411
|
artistName: external_exports.string().optional().describe("Artist name"),
|
|
@@ -31606,6 +31628,151 @@ function registerUserTools(server) {
|
|
|
31606
31628
|
);
|
|
31607
31629
|
}
|
|
31608
31630
|
|
|
31631
|
+
// src/tools/resolve.ts
|
|
31632
|
+
var MAX_BATCH = 24;
|
|
31633
|
+
var PACE_MS = 500;
|
|
31634
|
+
var BUDGET_MS = 45e3;
|
|
31635
|
+
function songCountOf(s) {
|
|
31636
|
+
const sets = Array.isArray(s.sets?.set) ? s.sets.set : [];
|
|
31637
|
+
return sets.reduce((n, set2) => n + (Array.isArray(set2?.song) ? set2.song.length : 0), 0);
|
|
31638
|
+
}
|
|
31639
|
+
function normalizeArtist(name) {
|
|
31640
|
+
return name.replace(/["'’‘”“]/g, "").replace(/\s*[+&]\s*/g, " and ").replace(/\./g, "").replace(/\s+/g, " ").trim();
|
|
31641
|
+
}
|
|
31642
|
+
async function emptyOn404(run, fallback) {
|
|
31643
|
+
try {
|
|
31644
|
+
return await run();
|
|
31645
|
+
} catch (err) {
|
|
31646
|
+
if (/\b404\b/.test(messageOf(err))) return fallback;
|
|
31647
|
+
throw err;
|
|
31648
|
+
}
|
|
31649
|
+
}
|
|
31650
|
+
async function searchSetlists(req, query) {
|
|
31651
|
+
return emptyOn404(async () => {
|
|
31652
|
+
const data = await req("GET", "/1.0/search/setlists", { query });
|
|
31653
|
+
return data?.setlist ?? [];
|
|
31654
|
+
}, []);
|
|
31655
|
+
}
|
|
31656
|
+
async function topArtistMbid(req, artistName) {
|
|
31657
|
+
return emptyOn404(async () => {
|
|
31658
|
+
const data = await req("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(req, 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(req, { artistName: c.artist, ...filters });
|
|
31684
|
+
if (list.length === 0) {
|
|
31685
|
+
const mbid = await topArtistMbid(req, c.artist);
|
|
31686
|
+
if (mbid) list = await searchSetlists(req, { 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(req, { 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
|
+
var defaultSleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31715
|
+
async function resolveConcerts(concerts, deps = {}) {
|
|
31716
|
+
const baseRequest = deps.request ?? ((m, p, o) => client.request(m, p, o));
|
|
31717
|
+
const sleep = deps.sleep ?? defaultSleep2;
|
|
31718
|
+
const now = deps.now ?? Date.now;
|
|
31719
|
+
const paceMs = deps.paceMs ?? PACE_MS;
|
|
31720
|
+
const budgetMs = deps.budgetMs ?? BUDGET_MS;
|
|
31721
|
+
let lastCallAt = 0;
|
|
31722
|
+
const req = async (method, path, opts) => {
|
|
31723
|
+
const wait = paceMs - (now() - lastCallAt);
|
|
31724
|
+
if (wait > 0) await sleep(wait);
|
|
31725
|
+
lastCallAt = now();
|
|
31726
|
+
return baseRequest(method, path, opts);
|
|
31727
|
+
};
|
|
31728
|
+
const start = now();
|
|
31729
|
+
const results = [];
|
|
31730
|
+
let budgetSpent = false;
|
|
31731
|
+
for (const c of concerts) {
|
|
31732
|
+
if (budgetSpent || now() - start >= budgetMs) {
|
|
31733
|
+
budgetSpent = true;
|
|
31734
|
+
results.push({ input: c, match: null, alternatives: 0, pending: true });
|
|
31735
|
+
continue;
|
|
31736
|
+
}
|
|
31737
|
+
results.push(await resolveOne(req, c));
|
|
31738
|
+
}
|
|
31739
|
+
return results;
|
|
31740
|
+
}
|
|
31741
|
+
function summarizeResults(results) {
|
|
31742
|
+
const pending = results.filter((r) => r.pending).length;
|
|
31743
|
+
const matched = results.filter((r) => r.match).length;
|
|
31744
|
+
const stubs = results.filter((r) => r.match && !r.match.hasSongs).length;
|
|
31745
|
+
const summary = { total: results.length, matched, stubs, unmatched: results.length - matched - pending, pending };
|
|
31746
|
+
const payload = { results, summary };
|
|
31747
|
+
if (pending > 0) {
|
|
31748
|
+
payload.note = `Reached the time budget after ${results.length - pending} of ${results.length} concerts. Re-call setlist_resolve_concerts with just the ${pending} pending concert(s).`;
|
|
31749
|
+
}
|
|
31750
|
+
return payload;
|
|
31751
|
+
}
|
|
31752
|
+
function registerResolveTools(server) {
|
|
31753
|
+
server.registerTool(
|
|
31754
|
+
"setlist_resolve_concerts",
|
|
31755
|
+
{
|
|
31756
|
+
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, pending}` 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. Calls are paced to setlist.fm's ~2 req/sec limit; if a big batch can't finish within the time budget the rest come back `pending: true` (re-call with just those) rather than timing out. Keep batches \u226424." + ATTRIBUTION_NOTE,
|
|
31757
|
+
annotations: { readOnlyHint: true },
|
|
31758
|
+
inputSchema: {
|
|
31759
|
+
concerts: external_exports.array(
|
|
31760
|
+
external_exports.object({
|
|
31761
|
+
artist: external_exports.string().describe("Artist name"),
|
|
31762
|
+
date: external_exports.string().describe("Event date, ISO yyyy-MM-dd (e.g. 2025-08-28)"),
|
|
31763
|
+
city: external_exports.string().optional().describe("City to disambiguate multi-city dates (optional)"),
|
|
31764
|
+
venue: external_exports.string().optional().describe("Venue to disambiguate (optional)")
|
|
31765
|
+
})
|
|
31766
|
+
).min(1).max(MAX_BATCH).describe(`Concerts to resolve (1\u2013${MAX_BATCH} per call)`)
|
|
31767
|
+
}
|
|
31768
|
+
},
|
|
31769
|
+
async ({ concerts }) => {
|
|
31770
|
+
const results = await resolveConcerts(concerts);
|
|
31771
|
+
return textResult(summarizeResults(results));
|
|
31772
|
+
}
|
|
31773
|
+
);
|
|
31774
|
+
}
|
|
31775
|
+
|
|
31609
31776
|
// src/tools/utilities.ts
|
|
31610
31777
|
function registerUtilityTools(server) {
|
|
31611
31778
|
server.registerTool(
|
|
@@ -31657,6 +31824,7 @@ await runMcp({
|
|
|
31657
31824
|
registerVenueTools,
|
|
31658
31825
|
registerGeoTools,
|
|
31659
31826
|
registerUserTools,
|
|
31827
|
+
registerResolveTools,
|
|
31660
31828
|
registerUtilityTools
|
|
31661
31829
|
]
|
|
31662
31830
|
});
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, join } from 'path';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
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.
|
|
@@ -61,8 +62,9 @@ export class SetlistClient {
|
|
|
61
62
|
...(opts.query !== undefined ? { query: opts.query } : {}),
|
|
62
63
|
...(opts.body !== undefined ? { body: opts.body } : {}),
|
|
63
64
|
});
|
|
64
|
-
// Surface every date as ISO yyyy-MM-dd (the API returns eventDate as dd-MM-yyyy)
|
|
65
|
-
|
|
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));
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
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,181 @@
|
|
|
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
|
+
// Pace upstream calls to ~2 req/sec (setlist.fm's standard-tier limit). Bursting
|
|
7
|
+
// a whole batch trips the limit → 429s → retries stack up → the tool call times
|
|
8
|
+
// out; spacing the requests keeps the batch steady and predictable.
|
|
9
|
+
const PACE_MS = 500;
|
|
10
|
+
// Overall wall-clock budget for one tool call. When it's exhausted, the
|
|
11
|
+
// remaining concerts come back `pending` instead of timing the whole call out.
|
|
12
|
+
const BUDGET_MS = 45_000;
|
|
13
|
+
function songCountOf(s) {
|
|
14
|
+
const sets = Array.isArray(s.sets?.set) ? s.sets.set : [];
|
|
15
|
+
return sets.reduce((n, set) => n + (Array.isArray(set?.song) ? set.song.length : 0), 0);
|
|
16
|
+
}
|
|
17
|
+
// Loosen punctuation/format variants for a fuzzy retry: drop quotes, turn + / &
|
|
18
|
+
// into "and", drop stray periods, collapse whitespace. (Dan + Shay, "Weird Al"
|
|
19
|
+
// Yankovic, DJ Pee .Wee.)
|
|
20
|
+
function normalizeArtist(name) {
|
|
21
|
+
return name
|
|
22
|
+
.replace(/["'’‘”“]/g, '')
|
|
23
|
+
.replace(/\s*[+&]\s*/g, ' and ')
|
|
24
|
+
.replace(/\./g, '')
|
|
25
|
+
.replace(/\s+/g, ' ')
|
|
26
|
+
.trim();
|
|
27
|
+
}
|
|
28
|
+
// A no-match search returns HTTP 404 from setlist.fm — treat that as "empty",
|
|
29
|
+
// not an error; let anything else propagate.
|
|
30
|
+
async function emptyOn404(run, fallback) {
|
|
31
|
+
try {
|
|
32
|
+
return await run();
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (/\b404\b/.test(messageOf(err)))
|
|
36
|
+
return fallback;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function searchSetlists(req, query) {
|
|
41
|
+
return emptyOn404(async () => {
|
|
42
|
+
const data = await req('GET', '/1.0/search/setlists', { query });
|
|
43
|
+
return data?.setlist ?? [];
|
|
44
|
+
}, []);
|
|
45
|
+
}
|
|
46
|
+
async function topArtistMbid(req, artistName) {
|
|
47
|
+
return emptyOn404(async () => {
|
|
48
|
+
const data = await req('GET', '/1.0/search/artists', {
|
|
49
|
+
query: { artistName, sort: 'relevance' },
|
|
50
|
+
});
|
|
51
|
+
return data?.artist?.[0]?.mbid;
|
|
52
|
+
}, undefined);
|
|
53
|
+
}
|
|
54
|
+
function score(s, city, venue) {
|
|
55
|
+
let sc = 0;
|
|
56
|
+
const vn = s.venue?.name?.toLowerCase() ?? '';
|
|
57
|
+
const cn = s.venue?.city?.name?.toLowerCase() ?? '';
|
|
58
|
+
if (venue && vn.includes(venue.toLowerCase()))
|
|
59
|
+
sc += 4;
|
|
60
|
+
if (city && cn.includes(city.toLowerCase()))
|
|
61
|
+
sc += 2;
|
|
62
|
+
if (songCountOf(s) > 0)
|
|
63
|
+
sc += 1;
|
|
64
|
+
return sc;
|
|
65
|
+
}
|
|
66
|
+
function pickBest(list, city, venue) {
|
|
67
|
+
if (list.length === 0)
|
|
68
|
+
return undefined;
|
|
69
|
+
// Best location/song score wins; prefer a populated setlist on ties via songCount.
|
|
70
|
+
return [...list].sort((a, b) => score(b, city, venue) - score(a, city, venue) || songCountOf(b) - songCountOf(a))[0];
|
|
71
|
+
}
|
|
72
|
+
async function resolveOne(req, c) {
|
|
73
|
+
const filters = {
|
|
74
|
+
date: isoToDmy(c.date),
|
|
75
|
+
...(c.city ? { cityName: c.city } : {}),
|
|
76
|
+
...(c.venue ? { venueName: c.venue } : {}),
|
|
77
|
+
};
|
|
78
|
+
let list = await searchSetlists(req, { artistName: c.artist, ...filters });
|
|
79
|
+
// Fuzzy fallback 1: resolve the artist via the (more forgiving) relevance
|
|
80
|
+
// search, then query by its mbid.
|
|
81
|
+
if (list.length === 0) {
|
|
82
|
+
const mbid = await topArtistMbid(req, c.artist);
|
|
83
|
+
if (mbid)
|
|
84
|
+
list = await searchSetlists(req, { artistMbid: mbid, ...filters });
|
|
85
|
+
}
|
|
86
|
+
// Fuzzy fallback 2: a punctuation-normalized name.
|
|
87
|
+
if (list.length === 0) {
|
|
88
|
+
const norm = normalizeArtist(c.artist);
|
|
89
|
+
if (norm && norm.toLowerCase() !== c.artist.toLowerCase()) {
|
|
90
|
+
list = await searchSetlists(req, { artistName: norm, ...filters });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const best = pickBest(list, c.city, c.venue);
|
|
94
|
+
if (!best)
|
|
95
|
+
return { input: c, match: null, alternatives: 0 };
|
|
96
|
+
const songCount = songCountOf(best);
|
|
97
|
+
return {
|
|
98
|
+
input: c,
|
|
99
|
+
match: {
|
|
100
|
+
setlistId: best.id,
|
|
101
|
+
url: best.url,
|
|
102
|
+
eventDate: best.eventDate, // already ISO (normalized by client.request)
|
|
103
|
+
artist: best.artist?.name,
|
|
104
|
+
venue: best.venue?.name,
|
|
105
|
+
city: best.venue?.city?.name,
|
|
106
|
+
tour: best.tour?.name,
|
|
107
|
+
songCount,
|
|
108
|
+
hasSongs: songCount > 0,
|
|
109
|
+
},
|
|
110
|
+
alternatives: list.length - 1,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
114
|
+
/**
|
|
115
|
+
* Resolve each concert sequentially, pacing upstream calls to stay under
|
|
116
|
+
* setlist.fm's rate limit and stopping (remaining → `pending`) once the
|
|
117
|
+
* wall-clock budget is spent, so a large batch returns partial results instead
|
|
118
|
+
* of timing the whole call out. Exported for testing.
|
|
119
|
+
*/
|
|
120
|
+
export async function resolveConcerts(concerts, deps = {}) {
|
|
121
|
+
const baseRequest = deps.request ?? ((m, p, o) => client.request(m, p, o));
|
|
122
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
123
|
+
const now = deps.now ?? Date.now;
|
|
124
|
+
const paceMs = deps.paceMs ?? PACE_MS;
|
|
125
|
+
const budgetMs = deps.budgetMs ?? BUDGET_MS;
|
|
126
|
+
// Gate every upstream call to at least `paceMs` apart (the first runs immediately).
|
|
127
|
+
let lastCallAt = 0;
|
|
128
|
+
const req = async (method, path, opts) => {
|
|
129
|
+
const wait = paceMs - (now() - lastCallAt);
|
|
130
|
+
if (wait > 0)
|
|
131
|
+
await sleep(wait);
|
|
132
|
+
lastCallAt = now();
|
|
133
|
+
return baseRequest(method, path, opts);
|
|
134
|
+
};
|
|
135
|
+
const start = now();
|
|
136
|
+
const results = [];
|
|
137
|
+
let budgetSpent = false;
|
|
138
|
+
for (const c of concerts) {
|
|
139
|
+
if (budgetSpent || now() - start >= budgetMs) {
|
|
140
|
+
budgetSpent = true;
|
|
141
|
+
results.push({ input: c, match: null, alternatives: 0, pending: true });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
results.push(await resolveOne(req, c));
|
|
145
|
+
}
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
/** Build the tool payload (results + summary, plus a note when work was deferred). */
|
|
149
|
+
export function summarizeResults(results) {
|
|
150
|
+
const pending = results.filter((r) => r.pending).length;
|
|
151
|
+
const matched = results.filter((r) => r.match).length;
|
|
152
|
+
const stubs = results.filter((r) => r.match && !r.match.hasSongs).length;
|
|
153
|
+
const summary = { total: results.length, matched, stubs, unmatched: results.length - matched - pending, pending };
|
|
154
|
+
const payload = { results, summary };
|
|
155
|
+
if (pending > 0) {
|
|
156
|
+
payload.note = `Reached the time budget after ${results.length - pending} of ${results.length} concerts. Re-call setlist_resolve_concerts with just the ${pending} pending concert(s).`;
|
|
157
|
+
}
|
|
158
|
+
return payload;
|
|
159
|
+
}
|
|
160
|
+
export function registerResolveTools(server) {
|
|
161
|
+
server.registerTool('setlist_resolve_concerts', {
|
|
162
|
+
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, pending}` 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. Calls are paced to setlist.fm's ~2 req/sec limit; if a big batch can't finish within the time budget the rest come back `pending: true` (re-call with just those) rather than timing out. Keep batches ≤24." +
|
|
163
|
+
ATTRIBUTION_NOTE,
|
|
164
|
+
annotations: { readOnlyHint: true },
|
|
165
|
+
inputSchema: {
|
|
166
|
+
concerts: z
|
|
167
|
+
.array(z.object({
|
|
168
|
+
artist: z.string().describe('Artist name'),
|
|
169
|
+
date: z.string().describe('Event date, ISO yyyy-MM-dd (e.g. 2025-08-28)'),
|
|
170
|
+
city: z.string().optional().describe('City to disambiguate multi-city dates (optional)'),
|
|
171
|
+
venue: z.string().optional().describe('Venue to disambiguate (optional)'),
|
|
172
|
+
}))
|
|
173
|
+
.min(1)
|
|
174
|
+
.max(MAX_BATCH)
|
|
175
|
+
.describe(`Concerts to resolve (1–${MAX_BATCH} per call)`),
|
|
176
|
+
},
|
|
177
|
+
}, async ({ concerts }) => {
|
|
178
|
+
const results = await resolveConcerts(concerts);
|
|
179
|
+
return textResult(summarizeResults(results));
|
|
180
|
+
});
|
|
181
|
+
}
|
package/dist/tools/setlists.js
CHANGED
|
@@ -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: {
|
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.1'; // 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.1",
|
|
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>",
|
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.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "setlist-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.5.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|