setlist-mcp 0.5.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 +3 -0
- package/dist/bundle.js +53 -23
- package/dist/tools/resolve.js +66 -24
- 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.5.
|
|
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.5.
|
|
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`.
|
package/dist/bundle.js
CHANGED
|
@@ -31258,7 +31258,7 @@ function toolAnnotations(opts = {}) {
|
|
|
31258
31258
|
}
|
|
31259
31259
|
|
|
31260
31260
|
// src/version.ts
|
|
31261
|
-
var VERSION = "0.5.
|
|
31261
|
+
var VERSION = "0.5.1";
|
|
31262
31262
|
|
|
31263
31263
|
// src/client.ts
|
|
31264
31264
|
import { dirname, join } from "path";
|
|
@@ -31630,6 +31630,8 @@ function registerUserTools(server) {
|
|
|
31630
31630
|
|
|
31631
31631
|
// src/tools/resolve.ts
|
|
31632
31632
|
var MAX_BATCH = 24;
|
|
31633
|
+
var PACE_MS = 500;
|
|
31634
|
+
var BUDGET_MS = 45e3;
|
|
31633
31635
|
function songCountOf(s) {
|
|
31634
31636
|
const sets = Array.isArray(s.sets?.set) ? s.sets.set : [];
|
|
31635
31637
|
return sets.reduce((n, set2) => n + (Array.isArray(set2?.song) ? set2.song.length : 0), 0);
|
|
@@ -31645,17 +31647,15 @@ async function emptyOn404(run, fallback) {
|
|
|
31645
31647
|
throw err;
|
|
31646
31648
|
}
|
|
31647
31649
|
}
|
|
31648
|
-
async function searchSetlists(query) {
|
|
31650
|
+
async function searchSetlists(req, query) {
|
|
31649
31651
|
return emptyOn404(async () => {
|
|
31650
|
-
const data = await
|
|
31651
|
-
query
|
|
31652
|
-
});
|
|
31652
|
+
const data = await req("GET", "/1.0/search/setlists", { query });
|
|
31653
31653
|
return data?.setlist ?? [];
|
|
31654
31654
|
}, []);
|
|
31655
31655
|
}
|
|
31656
|
-
async function topArtistMbid(artistName) {
|
|
31656
|
+
async function topArtistMbid(req, artistName) {
|
|
31657
31657
|
return emptyOn404(async () => {
|
|
31658
|
-
const data = await
|
|
31658
|
+
const data = await req("GET", "/1.0/search/artists", {
|
|
31659
31659
|
query: { artistName, sort: "relevance" }
|
|
31660
31660
|
});
|
|
31661
31661
|
return data?.artist?.[0]?.mbid;
|
|
@@ -31674,21 +31674,21 @@ function pickBest(list, city, venue) {
|
|
|
31674
31674
|
if (list.length === 0) return void 0;
|
|
31675
31675
|
return [...list].sort((a, b) => score(b, city, venue) - score(a, city, venue) || songCountOf(b) - songCountOf(a))[0];
|
|
31676
31676
|
}
|
|
31677
|
-
async function resolveOne(c) {
|
|
31677
|
+
async function resolveOne(req, c) {
|
|
31678
31678
|
const filters = {
|
|
31679
31679
|
date: isoToDmy(c.date),
|
|
31680
31680
|
...c.city ? { cityName: c.city } : {},
|
|
31681
31681
|
...c.venue ? { venueName: c.venue } : {}
|
|
31682
31682
|
};
|
|
31683
|
-
let list = await searchSetlists({ artistName: c.artist, ...filters });
|
|
31683
|
+
let list = await searchSetlists(req, { artistName: c.artist, ...filters });
|
|
31684
31684
|
if (list.length === 0) {
|
|
31685
|
-
const mbid = await topArtistMbid(c.artist);
|
|
31686
|
-
if (mbid) list = await searchSetlists({ artistMbid: mbid, ...filters });
|
|
31685
|
+
const mbid = await topArtistMbid(req, c.artist);
|
|
31686
|
+
if (mbid) list = await searchSetlists(req, { artistMbid: mbid, ...filters });
|
|
31687
31687
|
}
|
|
31688
31688
|
if (list.length === 0) {
|
|
31689
31689
|
const norm = normalizeArtist(c.artist);
|
|
31690
31690
|
if (norm && norm.toLowerCase() !== c.artist.toLowerCase()) {
|
|
31691
|
-
list = await searchSetlists({ artistName: norm, ...filters });
|
|
31691
|
+
list = await searchSetlists(req, { artistName: norm, ...filters });
|
|
31692
31692
|
}
|
|
31693
31693
|
}
|
|
31694
31694
|
const best = pickBest(list, c.city, c.venue);
|
|
@@ -31711,11 +31711,49 @@ async function resolveOne(c) {
|
|
|
31711
31711
|
alternatives: list.length - 1
|
|
31712
31712
|
};
|
|
31713
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
|
+
}
|
|
31714
31752
|
function registerResolveTools(server) {
|
|
31715
31753
|
server.registerTool(
|
|
31716
31754
|
"setlist_resolve_concerts",
|
|
31717
31755
|
{
|
|
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.
|
|
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,
|
|
31719
31757
|
annotations: { readOnlyHint: true },
|
|
31720
31758
|
inputSchema: {
|
|
31721
31759
|
concerts: external_exports.array(
|
|
@@ -31729,16 +31767,8 @@ function registerResolveTools(server) {
|
|
|
31729
31767
|
}
|
|
31730
31768
|
},
|
|
31731
31769
|
async ({ concerts }) => {
|
|
31732
|
-
const results =
|
|
31733
|
-
|
|
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
|
-
});
|
|
31770
|
+
const results = await resolveConcerts(concerts);
|
|
31771
|
+
return textResult(summarizeResults(results));
|
|
31742
31772
|
}
|
|
31743
31773
|
);
|
|
31744
31774
|
}
|
package/dist/tools/resolve.js
CHANGED
|
@@ -3,6 +3,13 @@ import { textResult, isoToDmy, messageOf } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { client } from '../client.js';
|
|
4
4
|
import { ATTRIBUTION_NOTE } from '../attribution.js';
|
|
5
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;
|
|
6
13
|
function songCountOf(s) {
|
|
7
14
|
const sets = Array.isArray(s.sets?.set) ? s.sets.set : [];
|
|
8
15
|
return sets.reduce((n, set) => n + (Array.isArray(set?.song) ? set.song.length : 0), 0);
|
|
@@ -30,17 +37,15 @@ async function emptyOn404(run, fallback) {
|
|
|
30
37
|
throw err;
|
|
31
38
|
}
|
|
32
39
|
}
|
|
33
|
-
async function searchSetlists(query) {
|
|
40
|
+
async function searchSetlists(req, query) {
|
|
34
41
|
return emptyOn404(async () => {
|
|
35
|
-
const data = await
|
|
36
|
-
query,
|
|
37
|
-
});
|
|
42
|
+
const data = await req('GET', '/1.0/search/setlists', { query });
|
|
38
43
|
return data?.setlist ?? [];
|
|
39
44
|
}, []);
|
|
40
45
|
}
|
|
41
|
-
async function topArtistMbid(artistName) {
|
|
46
|
+
async function topArtistMbid(req, artistName) {
|
|
42
47
|
return emptyOn404(async () => {
|
|
43
|
-
const data = await
|
|
48
|
+
const data = await req('GET', '/1.0/search/artists', {
|
|
44
49
|
query: { artistName, sort: 'relevance' },
|
|
45
50
|
});
|
|
46
51
|
return data?.artist?.[0]?.mbid;
|
|
@@ -64,25 +69,25 @@ function pickBest(list, city, venue) {
|
|
|
64
69
|
// Best location/song score wins; prefer a populated setlist on ties via songCount.
|
|
65
70
|
return [...list].sort((a, b) => score(b, city, venue) - score(a, city, venue) || songCountOf(b) - songCountOf(a))[0];
|
|
66
71
|
}
|
|
67
|
-
async function resolveOne(c) {
|
|
72
|
+
async function resolveOne(req, c) {
|
|
68
73
|
const filters = {
|
|
69
74
|
date: isoToDmy(c.date),
|
|
70
75
|
...(c.city ? { cityName: c.city } : {}),
|
|
71
76
|
...(c.venue ? { venueName: c.venue } : {}),
|
|
72
77
|
};
|
|
73
|
-
let list = await searchSetlists({ artistName: c.artist, ...filters });
|
|
78
|
+
let list = await searchSetlists(req, { artistName: c.artist, ...filters });
|
|
74
79
|
// Fuzzy fallback 1: resolve the artist via the (more forgiving) relevance
|
|
75
80
|
// search, then query by its mbid.
|
|
76
81
|
if (list.length === 0) {
|
|
77
|
-
const mbid = await topArtistMbid(c.artist);
|
|
82
|
+
const mbid = await topArtistMbid(req, c.artist);
|
|
78
83
|
if (mbid)
|
|
79
|
-
list = await searchSetlists({ artistMbid: mbid, ...filters });
|
|
84
|
+
list = await searchSetlists(req, { artistMbid: mbid, ...filters });
|
|
80
85
|
}
|
|
81
86
|
// Fuzzy fallback 2: a punctuation-normalized name.
|
|
82
87
|
if (list.length === 0) {
|
|
83
88
|
const norm = normalizeArtist(c.artist);
|
|
84
89
|
if (norm && norm.toLowerCase() !== c.artist.toLowerCase()) {
|
|
85
|
-
list = await searchSetlists({ artistName: norm, ...filters });
|
|
90
|
+
list = await searchSetlists(req, { artistName: norm, ...filters });
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
93
|
const best = pickBest(list, c.city, c.venue);
|
|
@@ -105,9 +110,56 @@ async function resolveOne(c) {
|
|
|
105
110
|
alternatives: list.length - 1,
|
|
106
111
|
};
|
|
107
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
|
+
}
|
|
108
160
|
export function registerResolveTools(server) {
|
|
109
161
|
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.
|
|
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." +
|
|
111
163
|
ATTRIBUTION_NOTE,
|
|
112
164
|
annotations: { readOnlyHint: true },
|
|
113
165
|
inputSchema: {
|
|
@@ -123,17 +175,7 @@ export function registerResolveTools(server) {
|
|
|
123
175
|
.describe(`Concerts to resolve (1–${MAX_BATCH} per call)`),
|
|
124
176
|
},
|
|
125
177
|
}, async ({ concerts }) => {
|
|
126
|
-
const results =
|
|
127
|
-
|
|
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
|
-
});
|
|
178
|
+
const results = await resolveConcerts(concerts);
|
|
179
|
+
return textResult(summarizeResults(results));
|
|
138
180
|
});
|
|
139
181
|
}
|
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.5.
|
|
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.5.
|
|
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.5.
|
|
9
|
+
"version": "0.5.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "setlist-mcp",
|
|
14
|
-
"version": "0.5.
|
|
14
|
+
"version": "0.5.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|