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.
@@ -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.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.5.0",
18
+ "version": "0.5.1",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "setlist-mcp",
3
3
  "displayName": "setlist.fm",
4
- "version": "0.5.0",
4
+ "version": "0.5.1",
5
5
  "description": "MCP server for setlist.fm — search concert setlists, artists, venues, and tours via the setlist.fm API",
6
6
  "author": {
7
7
  "name": "Chris Hall",
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.0";
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 client.request("GET", "/1.0/search/setlists", {
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 client.request("GET", "/1.0/search/artists", {
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. Processed sequentially to respect setlist.fm's rate limit; chunk lists longer than 24 across calls." + ATTRIBUTION_NOTE,
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
- 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
- });
31770
+ const results = await resolveConcerts(concerts);
31771
+ return textResult(summarizeResults(results));
31742
31772
  }
31743
31773
  );
31744
31774
  }
@@ -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 client.request('GET', '/1.0/search/setlists', {
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 client.request('GET', '/1.0/search/artists', {
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. Processed sequentially to respect setlist.fm's rate limit; chunk lists longer than 24 across calls." +
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
- // 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
- });
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.0'; // x-release-please-version
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.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.5.0",
9
+ "version": "0.5.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "setlist-mcp",
14
- "version": "0.5.0",
14
+ "version": "0.5.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },