setlist-mcp 0.5.0 → 0.6.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 +3 -0
- package/dist/bundle.js +38244 -29689
- package/dist/fetchproxy-cookie.js +52 -0
- package/dist/index.js +2 -0
- package/dist/tools/attendance.js +100 -0
- package/dist/tools/resolve.js +146 -26
- package/dist/version.js +1 -1
- package/dist/web-client.js +79 -0
- package/package.json +4 -2
- package/server.json +9 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
+
import { VERSION } from './version.js';
|
|
3
|
+
// The cookies that authenticate a www.setlist.fm session: JSESSIONID is the
|
|
4
|
+
// (HttpOnly) Wicket session, RememberMeCookie re-establishes login, aws-waf-token
|
|
5
|
+
// clears the WAF. read_cookies uses chrome.cookies.get, which DOES see HttpOnly
|
|
6
|
+
// cookies (page JS cannot), so the bridge can lift JSESSIONID.
|
|
7
|
+
const SESSION_COOKIE_KEYS = ['JSESSIONID', 'RememberMeCookie', 'aws-waf-token'];
|
|
8
|
+
// Bound the bridge round-trip so a wedged/absent extension fails fast instead of
|
|
9
|
+
// hanging the tool call.
|
|
10
|
+
const BOOTSTRAP_TIMEOUT_MS = 15_000;
|
|
11
|
+
function fetchproxyDisabled() {
|
|
12
|
+
const raw = readEnvVar('SETLIST_DISABLE_FETCHPROXY');
|
|
13
|
+
return raw !== undefined && ['1', 'true', 'yes', 'on'].includes(raw.toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
function withTimeout(p, ms) {
|
|
16
|
+
return Promise.race([
|
|
17
|
+
p,
|
|
18
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('fetchproxy: timed out waiting for the browser bridge. Is the Transporter extension running and signed into setlist.fm in that browser?')), ms).unref?.()),
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* One-shot fetchproxy bootstrap: lift the logged-in setlist.fm session cookies
|
|
23
|
+
* out of the signed-in browser tab (via the Transporter extension), assemble a
|
|
24
|
+
* `Cookie` header, and return it — fetchproxy is NOT in the hot path afterward.
|
|
25
|
+
* Used only when `SETLIST_SESSION_COOKIE` is unset.
|
|
26
|
+
*
|
|
27
|
+
* Returns `null` when the fallback is disabled; throws an actionable error if the
|
|
28
|
+
* bridge is unreachable or no session cookie is present.
|
|
29
|
+
*/
|
|
30
|
+
export async function grabSessionCookie() {
|
|
31
|
+
if (fetchproxyDisabled())
|
|
32
|
+
return null;
|
|
33
|
+
let bootstrap;
|
|
34
|
+
try {
|
|
35
|
+
({ bootstrap } = await import('@fetchproxy/bootstrap'));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null; // bridge package unavailable (shouldn't happen — bundled)
|
|
39
|
+
}
|
|
40
|
+
const session = await withTimeout(bootstrap({
|
|
41
|
+
serverName: 'setlist-mcp',
|
|
42
|
+
version: VERSION,
|
|
43
|
+
domains: ['www.setlist.fm'],
|
|
44
|
+
declare: { cookies: SESSION_COOKIE_KEYS, localStorage: [], sessionStorage: [], captureHeaders: [] },
|
|
45
|
+
}), BOOTSTRAP_TIMEOUT_MS);
|
|
46
|
+
const cookies = session.cookies ?? {};
|
|
47
|
+
const header = SESSION_COOKIE_KEYS.filter((k) => cookies[k]).map((k) => `${k}=${cookies[k]}`).join('; ');
|
|
48
|
+
if (!cookies.JSESSIONID && !cookies.RememberMeCookie) {
|
|
49
|
+
throw new Error('fetchproxy: no setlist.fm session cookie found — sign in at https://www.setlist.fm in the browser running the Transporter extension, then retry.');
|
|
50
|
+
}
|
|
51
|
+
return header;
|
|
52
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { registerVenueTools } from './tools/venues.js';
|
|
|
7
7
|
import { registerGeoTools } from './tools/geo.js';
|
|
8
8
|
import { registerUserTools } from './tools/users.js';
|
|
9
9
|
import { registerResolveTools } from './tools/resolve.js';
|
|
10
|
+
import { registerAttendanceTools } from './tools/attendance.js';
|
|
10
11
|
import { registerUtilityTools } from './tools/utilities.js';
|
|
11
12
|
// The setlist.fm client is a module-level singleton (imported by each tool
|
|
12
13
|
// module) that defers its config error to the first request. That preserves the
|
|
@@ -24,6 +25,7 @@ await runMcp({
|
|
|
24
25
|
registerGeoTools,
|
|
25
26
|
registerUserTools,
|
|
26
27
|
registerResolveTools,
|
|
28
|
+
registerAttendanceTools,
|
|
27
29
|
registerUtilityTools,
|
|
28
30
|
],
|
|
29
31
|
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
import { webClient } from '../web-client.js';
|
|
5
|
+
import { ATTRIBUTION_NOTE } from '../attribution.js';
|
|
6
|
+
function decodeEntities(s) {
|
|
7
|
+
return s
|
|
8
|
+
.replace(/&/g, '&')
|
|
9
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
|
|
10
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Locate the attendance toggle in a logged-in setlist page and read its state.
|
|
14
|
+
* The control is a Wicket `wicketAjaxGet(...)` anchor whose title is
|
|
15
|
+
* "Add this setlist to your attended shows." (not attended) or
|
|
16
|
+
* "Remove this setlist from your attended shows." (attended).
|
|
17
|
+
*/
|
|
18
|
+
export function parseAttendance(html) {
|
|
19
|
+
for (const seg of html.split(/<a\b/i).slice(1)) {
|
|
20
|
+
const a = '<a ' + seg.slice(0, 800);
|
|
21
|
+
if (!/wicketAjaxGet/.test(a))
|
|
22
|
+
continue;
|
|
23
|
+
const title = (a.match(/title="([^"]*attended shows[^"]*)"/i) || [])[1];
|
|
24
|
+
if (!title)
|
|
25
|
+
continue;
|
|
26
|
+
const url = (a.match(/wicketAjaxGet\(\s*['"]([^'"]+)['"]/) || [])[1];
|
|
27
|
+
if (!url)
|
|
28
|
+
continue;
|
|
29
|
+
return { ajaxUrl: decodeEntities(url), attended: /remove/i.test(title) };
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
async function setAttendance(setlistId, desired, confirm) {
|
|
34
|
+
// Resolve the canonical setlist page URL via the public API.
|
|
35
|
+
const meta = await client.request('GET', `/1.0/setlist/${encodeURIComponent(setlistId)}`);
|
|
36
|
+
if (!meta?.url)
|
|
37
|
+
throw new Error(`No setlist found for id "${setlistId}".`);
|
|
38
|
+
const path = new URL(meta.url).pathname;
|
|
39
|
+
const summary = {
|
|
40
|
+
setlistId,
|
|
41
|
+
url: meta.url,
|
|
42
|
+
artist: meta.artist?.name,
|
|
43
|
+
eventDate: meta.eventDate,
|
|
44
|
+
venue: meta.venue?.name,
|
|
45
|
+
city: meta.venue?.city?.name,
|
|
46
|
+
};
|
|
47
|
+
const control = parseAttendance(await webClient.fetchPage(path));
|
|
48
|
+
if (!control) {
|
|
49
|
+
throw new Error('Could not find the attendance control on the setlist page — your setlist.fm session has likely expired (re-copy SETLIST_SESSION_COOKIE) or the page layout changed.');
|
|
50
|
+
}
|
|
51
|
+
if (control.attended === desired) {
|
|
52
|
+
return {
|
|
53
|
+
...summary,
|
|
54
|
+
attended: desired,
|
|
55
|
+
changed: false,
|
|
56
|
+
note: desired ? 'Already marked as attended.' : 'Not currently attended — nothing to remove.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (!confirm) {
|
|
60
|
+
return {
|
|
61
|
+
...summary,
|
|
62
|
+
currentlyAttended: control.attended,
|
|
63
|
+
wouldSetAttendedTo: desired,
|
|
64
|
+
dryRun: true,
|
|
65
|
+
note: `Dry run — re-run with confirm: true to ${desired ? 'record' : 'remove'} this attendance.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// Replay the per-render Wicket toggle, then VERIFY by re-reading (a 200 is not proof).
|
|
69
|
+
const u = new URL(control.ajaxUrl, meta.url);
|
|
70
|
+
await webClient.wicketAjaxGet(u.pathname + u.search, path.replace(/^\//, ''));
|
|
71
|
+
const after = parseAttendance(await webClient.fetchPage(path));
|
|
72
|
+
const verified = after?.attended === desired;
|
|
73
|
+
return {
|
|
74
|
+
...summary,
|
|
75
|
+
attended: desired,
|
|
76
|
+
changed: true,
|
|
77
|
+
verified,
|
|
78
|
+
...(verified ? {} : { warning: 'Toggle sent, but the re-read did not confirm the new state — check on setlist.fm.' }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function registerAttendanceTools(server) {
|
|
82
|
+
server.registerTool('setlist_mark_attended', {
|
|
83
|
+
description: "Record on YOUR setlist.fm account that you attended a show — the site's \"I was there\" marker — by setlist ID. Authenticated via your session (needs SETLIST_SESSION_COOKIE). Idempotent: a no-op if already marked. Without confirm: true it returns a dry-run preview and makes NO change; with confirm: true it toggles attendance and verifies by re-reading your attended list." +
|
|
84
|
+
ATTRIBUTION_NOTE,
|
|
85
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
86
|
+
inputSchema: {
|
|
87
|
+
setlistId: z.string().describe('Setlist ID (e.g. from setlist_search_setlists / resolve_concerts)'),
|
|
88
|
+
confirm: z.boolean().optional().describe('Must be true to actually record attendance; omit for a dry-run preview.'),
|
|
89
|
+
},
|
|
90
|
+
}, async ({ setlistId, confirm }) => textResult(await setAttendance(setlistId, true, confirm === true)));
|
|
91
|
+
server.registerTool('setlist_unmark_attended', {
|
|
92
|
+
description: 'Remove a show from YOUR attended list on setlist.fm, by setlist ID (reverses setlist_mark_attended). Authenticated via your session. Idempotent: a no-op if not currently attended. Without confirm: true it returns a dry-run preview and makes NO change; with confirm: true it removes the attendance and verifies by re-reading.' +
|
|
93
|
+
ATTRIBUTION_NOTE,
|
|
94
|
+
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: true },
|
|
95
|
+
inputSchema: {
|
|
96
|
+
setlistId: z.string().describe('Setlist ID to remove from your attended shows'),
|
|
97
|
+
confirm: z.boolean().optional().describe('Must be true to actually remove attendance; omit for a dry-run preview.'),
|
|
98
|
+
},
|
|
99
|
+
}, async ({ setlistId, confirm }) => textResult(await setAttendance(setlistId, false, confirm === true)));
|
|
100
|
+
}
|
package/dist/tools/resolve.js
CHANGED
|
@@ -3,10 +3,36 @@ 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);
|
|
9
16
|
}
|
|
17
|
+
function songNamesOf(s) {
|
|
18
|
+
const sets = Array.isArray(s.sets?.set) ? s.sets.set : [];
|
|
19
|
+
const names = [];
|
|
20
|
+
for (const set of sets) {
|
|
21
|
+
for (const song of Array.isArray(set?.song) ? set.song : []) {
|
|
22
|
+
if (typeof song?.name === 'string')
|
|
23
|
+
names.push(song.name);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return names;
|
|
27
|
+
}
|
|
28
|
+
// Absolute day distance between two ISO dates; Infinity if either is unparseable.
|
|
29
|
+
function daysApart(isoA, isoB) {
|
|
30
|
+
const a = isoA ? Date.parse(isoA) : NaN;
|
|
31
|
+
const b = isoB ? Date.parse(isoB) : NaN;
|
|
32
|
+
if (Number.isNaN(a) || Number.isNaN(b))
|
|
33
|
+
return Number.POSITIVE_INFINITY;
|
|
34
|
+
return Math.abs(a - b) / 86_400_000;
|
|
35
|
+
}
|
|
10
36
|
// Loosen punctuation/format variants for a fuzzy retry: drop quotes, turn + / &
|
|
11
37
|
// into "and", drop stray periods, collapse whitespace. (Dan + Shay, "Weird Al"
|
|
12
38
|
// Yankovic, DJ Pee .Wee.)
|
|
@@ -30,17 +56,15 @@ async function emptyOn404(run, fallback) {
|
|
|
30
56
|
throw err;
|
|
31
57
|
}
|
|
32
58
|
}
|
|
33
|
-
async function searchSetlists(query) {
|
|
59
|
+
async function searchSetlists(req, query) {
|
|
34
60
|
return emptyOn404(async () => {
|
|
35
|
-
const data = await
|
|
36
|
-
query,
|
|
37
|
-
});
|
|
61
|
+
const data = await req('GET', '/1.0/search/setlists', { query });
|
|
38
62
|
return data?.setlist ?? [];
|
|
39
63
|
}, []);
|
|
40
64
|
}
|
|
41
|
-
async function topArtistMbid(artistName) {
|
|
65
|
+
async function topArtistMbid(req, artistName) {
|
|
42
66
|
return emptyOn404(async () => {
|
|
43
|
-
const data = await
|
|
67
|
+
const data = await req('GET', '/1.0/search/artists', {
|
|
44
68
|
query: { artistName, sort: 'relevance' },
|
|
45
69
|
});
|
|
46
70
|
return data?.artist?.[0]?.mbid;
|
|
@@ -64,32 +88,71 @@ function pickBest(list, city, venue) {
|
|
|
64
88
|
// Best location/song score wins; prefer a populated setlist on ties via songCount.
|
|
65
89
|
return [...list].sort((a, b) => score(b, city, venue) - score(a, city, venue) || songCountOf(b) - songCountOf(a))[0];
|
|
66
90
|
}
|
|
67
|
-
|
|
91
|
+
// When a show is an empty stub but the act toured a repeating set, find a
|
|
92
|
+
// populated setlist from the SAME tour (setlist.fm only) to offer as a labeled
|
|
93
|
+
// reference. Picks the populated date closest to the show. Returns undefined if
|
|
94
|
+
// the stub has no tour or no other populated tour date exists.
|
|
95
|
+
async function findTourReference(req, stub, targetDate) {
|
|
96
|
+
const tour = stub.tour?.name;
|
|
97
|
+
if (!tour)
|
|
98
|
+
return undefined;
|
|
99
|
+
const query = stub.artist?.mbid
|
|
100
|
+
? { artistMbid: stub.artist.mbid, tourName: tour }
|
|
101
|
+
: { artistName: stub.artist?.name, tourName: tour };
|
|
102
|
+
// Without an artist, a tour-name-only search could return a different act on a
|
|
103
|
+
// same-named tour — don't guess.
|
|
104
|
+
if (!query.artistMbid && !query.artistName)
|
|
105
|
+
return undefined;
|
|
106
|
+
const candidates = (await searchSetlists(req, query)).filter((s) => s.id !== stub.id && songCountOf(s) > 0);
|
|
107
|
+
if (candidates.length === 0)
|
|
108
|
+
return undefined;
|
|
109
|
+
// Prefer a *representative* night: a setlist that's substantially complete
|
|
110
|
+
// relative to the tour's best-documented show (≥60%), so we don't surface
|
|
111
|
+
// another thin/partial entry just because it's the nearest date. Among those,
|
|
112
|
+
// pick the date closest to the target show.
|
|
113
|
+
// The best-documented night always passes its own threshold, so `substantial`
|
|
114
|
+
// is non-empty whenever `candidates` is.
|
|
115
|
+
const maxSongs = Math.max(...candidates.map(songCountOf));
|
|
116
|
+
const substantial = candidates.filter((s) => songCountOf(s) >= Math.max(1, maxSongs * 0.6));
|
|
117
|
+
const ref = [...substantial].sort((a, b) => daysApart(a.eventDate, targetDate) - daysApart(b.eventDate, targetDate) || songCountOf(b) - songCountOf(a))[0];
|
|
118
|
+
return {
|
|
119
|
+
note: `No songs are logged for this show on setlist.fm. This is the typical set for the "${tour}" tour, from a different date — verify before treating it as this exact show's setlist.`,
|
|
120
|
+
tour,
|
|
121
|
+
fromDate: ref.eventDate,
|
|
122
|
+
fromVenue: ref.venue?.name,
|
|
123
|
+
fromCity: ref.venue?.city?.name,
|
|
124
|
+
setlistId: ref.id,
|
|
125
|
+
url: ref.url,
|
|
126
|
+
songCount: songCountOf(ref),
|
|
127
|
+
songs: songNamesOf(ref),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
async function resolveOne(req, c, tourFallback) {
|
|
68
131
|
const filters = {
|
|
69
132
|
date: isoToDmy(c.date),
|
|
70
133
|
...(c.city ? { cityName: c.city } : {}),
|
|
71
134
|
...(c.venue ? { venueName: c.venue } : {}),
|
|
72
135
|
};
|
|
73
|
-
let list = await searchSetlists({ artistName: c.artist, ...filters });
|
|
136
|
+
let list = await searchSetlists(req, { artistName: c.artist, ...filters });
|
|
74
137
|
// Fuzzy fallback 1: resolve the artist via the (more forgiving) relevance
|
|
75
138
|
// search, then query by its mbid.
|
|
76
139
|
if (list.length === 0) {
|
|
77
|
-
const mbid = await topArtistMbid(c.artist);
|
|
140
|
+
const mbid = await topArtistMbid(req, c.artist);
|
|
78
141
|
if (mbid)
|
|
79
|
-
list = await searchSetlists({ artistMbid: mbid, ...filters });
|
|
142
|
+
list = await searchSetlists(req, { artistMbid: mbid, ...filters });
|
|
80
143
|
}
|
|
81
144
|
// Fuzzy fallback 2: a punctuation-normalized name.
|
|
82
145
|
if (list.length === 0) {
|
|
83
146
|
const norm = normalizeArtist(c.artist);
|
|
84
147
|
if (norm && norm.toLowerCase() !== c.artist.toLowerCase()) {
|
|
85
|
-
list = await searchSetlists({ artistName: norm, ...filters });
|
|
148
|
+
list = await searchSetlists(req, { artistName: norm, ...filters });
|
|
86
149
|
}
|
|
87
150
|
}
|
|
88
151
|
const best = pickBest(list, c.city, c.venue);
|
|
89
152
|
if (!best)
|
|
90
153
|
return { input: c, match: null, alternatives: 0 };
|
|
91
154
|
const songCount = songCountOf(best);
|
|
92
|
-
|
|
155
|
+
const result = {
|
|
93
156
|
input: c,
|
|
94
157
|
match: {
|
|
95
158
|
setlistId: best.id,
|
|
@@ -104,10 +167,73 @@ async function resolveOne(c) {
|
|
|
104
167
|
},
|
|
105
168
|
alternatives: list.length - 1,
|
|
106
169
|
};
|
|
170
|
+
// Empty stub: try to offer the tour's typical (populated) setlist as a labeled reference.
|
|
171
|
+
if (songCount === 0 && tourFallback) {
|
|
172
|
+
const ref = await findTourReference(req, best, c.date);
|
|
173
|
+
if (ref)
|
|
174
|
+
result.tourReference = ref;
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
179
|
+
/**
|
|
180
|
+
* Resolve each concert sequentially, pacing upstream calls to stay under
|
|
181
|
+
* setlist.fm's rate limit and stopping (remaining → `pending`) once the
|
|
182
|
+
* wall-clock budget is spent, so a large batch returns partial results instead
|
|
183
|
+
* of timing the whole call out. Exported for testing.
|
|
184
|
+
*/
|
|
185
|
+
export async function resolveConcerts(concerts, deps = {}) {
|
|
186
|
+
const baseRequest = deps.request ?? ((m, p, o) => client.request(m, p, o));
|
|
187
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
188
|
+
const now = deps.now ?? Date.now;
|
|
189
|
+
const paceMs = deps.paceMs ?? PACE_MS;
|
|
190
|
+
const budgetMs = deps.budgetMs ?? BUDGET_MS;
|
|
191
|
+
const tourFallback = deps.tourFallback ?? true;
|
|
192
|
+
// Gate every upstream call to at least `paceMs` apart (the first runs immediately).
|
|
193
|
+
let lastCallAt = 0;
|
|
194
|
+
const req = async (method, path, opts) => {
|
|
195
|
+
const wait = paceMs - (now() - lastCallAt);
|
|
196
|
+
if (wait > 0)
|
|
197
|
+
await sleep(wait);
|
|
198
|
+
lastCallAt = now();
|
|
199
|
+
return baseRequest(method, path, opts);
|
|
200
|
+
};
|
|
201
|
+
const start = now();
|
|
202
|
+
const results = [];
|
|
203
|
+
let budgetSpent = false;
|
|
204
|
+
for (const c of concerts) {
|
|
205
|
+
if (budgetSpent || now() - start >= budgetMs) {
|
|
206
|
+
budgetSpent = true;
|
|
207
|
+
results.push({ input: c, match: null, alternatives: 0, pending: true });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
results.push(await resolveOne(req, c, tourFallback));
|
|
211
|
+
}
|
|
212
|
+
return results;
|
|
213
|
+
}
|
|
214
|
+
/** Build the tool payload (results + summary, plus a note when work was deferred). */
|
|
215
|
+
export function summarizeResults(results) {
|
|
216
|
+
const pending = results.filter((r) => r.pending).length;
|
|
217
|
+
const matched = results.filter((r) => r.match).length;
|
|
218
|
+
const stubs = results.filter((r) => r.match && !r.match.hasSongs).length;
|
|
219
|
+
const tourReferenced = results.filter((r) => r.tourReference).length;
|
|
220
|
+
const summary = {
|
|
221
|
+
total: results.length,
|
|
222
|
+
matched,
|
|
223
|
+
stubs,
|
|
224
|
+
tourReferenced,
|
|
225
|
+
unmatched: results.length - matched - pending,
|
|
226
|
+
pending,
|
|
227
|
+
};
|
|
228
|
+
const payload = { results, summary };
|
|
229
|
+
if (pending > 0) {
|
|
230
|
+
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).`;
|
|
231
|
+
}
|
|
232
|
+
return payload;
|
|
107
233
|
}
|
|
108
234
|
export function registerResolveTools(server) {
|
|
109
235
|
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.
|
|
236
|
+
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, tourReferenced, 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 (no songs logged on setlist.fm). When a show is a stub, if the act toured a repeating set the result also includes a `tourReference` — a populated setlist from the SAME tour on a different date (with `songs` + its own `url`), clearly labeled as a reference, NOT this exact show (set `tourFallback: false` to skip these extra lookups). 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
237
|
ATTRIBUTION_NOTE,
|
|
112
238
|
annotations: { readOnlyHint: true },
|
|
113
239
|
inputSchema: {
|
|
@@ -121,19 +247,13 @@ export function registerResolveTools(server) {
|
|
|
121
247
|
.min(1)
|
|
122
248
|
.max(MAX_BATCH)
|
|
123
249
|
.describe(`Concerts to resolve (1–${MAX_BATCH} per call)`),
|
|
250
|
+
tourFallback: z
|
|
251
|
+
.boolean()
|
|
252
|
+
.optional()
|
|
253
|
+
.describe('For empty stubs, also fetch a same-tour reference setlist (default true). Set false to skip the extra lookups.'),
|
|
124
254
|
},
|
|
125
|
-
}, 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
|
-
});
|
|
255
|
+
}, async ({ concerts, tourFallback }) => {
|
|
256
|
+
const results = await resolveConcerts(concerts, { tourFallback });
|
|
257
|
+
return textResult(summarizeResults(results));
|
|
138
258
|
});
|
|
139
259
|
}
|
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.6.0'; // x-release-please-version
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { dirname, join } from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { loadDotenvSafely, readEnvVar, createApiClient } from '@chrischall/mcp-utils';
|
|
4
|
+
// Load .env for local dev (guarded; the mcpb bundle omits dotenv).
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
|
|
7
|
+
const BASE_URL = 'https://www.setlist.fm';
|
|
8
|
+
const SERVICE_NAME = 'setlist.fm (web)';
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
10
|
+
// A browser-like UA; the bare default can trip the site's bot heuristics.
|
|
11
|
+
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
|
|
12
|
+
/**
|
|
13
|
+
* Authenticated client for the setlist.fm **website** (not the public REST API).
|
|
14
|
+
* The site has no JSON API for user actions — it's server-rendered HTML plus
|
|
15
|
+
* Apache Wicket stateful AJAX, authenticated by the logged-in session cookie.
|
|
16
|
+
*
|
|
17
|
+
* Auth resolves in order: `SETLIST_SESSION_COOKIE` (env) → a fetchproxy
|
|
18
|
+
* `read_cookies` bootstrap from the signed-in browser tab (lazy, optional) →
|
|
19
|
+
* a deferred config error at request time. Kept entirely separate from the
|
|
20
|
+
* api-key `SetlistClient` so the public-API tools never depend on a session.
|
|
21
|
+
*/
|
|
22
|
+
export class SetlistWebClient {
|
|
23
|
+
cookie;
|
|
24
|
+
configError;
|
|
25
|
+
api;
|
|
26
|
+
constructor() {
|
|
27
|
+
this.cookie = readEnvVar('SETLIST_SESSION_COOKIE') ?? null;
|
|
28
|
+
this.configError = new Error('No setlist.fm session: set SETLIST_SESSION_COOKIE (copy the Cookie header from a logged-in www.setlist.fm request) or connect the fetchproxy browser bridge.');
|
|
29
|
+
this.api = createApiClient({
|
|
30
|
+
baseUrl: BASE_URL,
|
|
31
|
+
serviceName: SERVICE_NAME,
|
|
32
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
33
|
+
retry: { count: 1, delayMs: 2000 },
|
|
34
|
+
baseHeaders: { 'User-Agent': USER_AGENT },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the session cookie: `SETLIST_SESSION_COOKIE` (env) first, else a
|
|
39
|
+
* one-time fetchproxy `read_cookies` grab from the signed-in tab (lazy-imported
|
|
40
|
+
* so the env path never loads the bridge), else the deferred config error.
|
|
41
|
+
* The grabbed cookie is cached on the instance for the process lifetime.
|
|
42
|
+
*/
|
|
43
|
+
async requireCookie() {
|
|
44
|
+
if (this.cookie)
|
|
45
|
+
return this.cookie;
|
|
46
|
+
const { grabSessionCookie } = await import('./fetchproxy-cookie.js');
|
|
47
|
+
const grabbed = await grabSessionCookie();
|
|
48
|
+
if (grabbed) {
|
|
49
|
+
this.cookie = grabbed;
|
|
50
|
+
return grabbed;
|
|
51
|
+
}
|
|
52
|
+
throw this.configError;
|
|
53
|
+
}
|
|
54
|
+
/** GET a page as HTML, authenticated. `path` is appended to the www base URL. */
|
|
55
|
+
async fetchPage(path) {
|
|
56
|
+
const cookie = await this.requireCookie();
|
|
57
|
+
return this.api.fetchHtml('GET', path, { headers: { Cookie: cookie } });
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Replay an Apache Wicket AJAX behavior GET (e.g. the attendance toggle).
|
|
61
|
+
* `ajaxPath` is the per-render URL parsed from a page's `wicketAjaxGet(...)`;
|
|
62
|
+
* `baseUrl` is the rendering page's path (no leading slash) for the
|
|
63
|
+
* `Wicket-Ajax-BaseURL` header. Returns the `<ajax-response>` XML body.
|
|
64
|
+
*/
|
|
65
|
+
async wicketAjaxGet(ajaxPath, baseUrl) {
|
|
66
|
+
const cookie = await this.requireCookie();
|
|
67
|
+
return this.api.fetchHtml('GET', ajaxPath, {
|
|
68
|
+
headers: {
|
|
69
|
+
Cookie: cookie,
|
|
70
|
+
'Wicket-Ajax': 'true',
|
|
71
|
+
'Wicket-Ajax-BaseURL': baseUrl,
|
|
72
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
73
|
+
Accept: 'text/xml, text/javascript, application/xml, text/html, */*',
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Module-level singleton (deferred-config: missing session surfaces at request time). */
|
|
79
|
+
export const webClient = new SetlistWebClient();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "setlist-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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>",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc && npm run bundle",
|
|
38
|
-
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
|
|
38
|
+
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
|
|
39
39
|
"dev": "node dist/index.js",
|
|
40
40
|
"test": "vitest run",
|
|
41
41
|
"test:watch": "vitest",
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@chrischall/mcp-utils": "^0.6.0",
|
|
46
|
+
"@fetchproxy/bootstrap": "^1.3.0",
|
|
47
|
+
"@fetchproxy/server": "^1.3.0",
|
|
46
48
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
47
49
|
"dotenv": "^17.4.0",
|
|
48
50
|
"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.6.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "setlist-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.6.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -29,6 +29,13 @@
|
|
|
29
29
|
"isRequired": false,
|
|
30
30
|
"format": "string",
|
|
31
31
|
"isSecret": false
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "SETLIST_SESSION_COOKIE",
|
|
35
|
+
"description": "Optional. Logged-in www.setlist.fm Cookie header — enables the attendance write tools.",
|
|
36
|
+
"isRequired": false,
|
|
37
|
+
"format": "string",
|
|
38
|
+
"isSecret": true
|
|
32
39
|
}
|
|
33
40
|
]
|
|
34
41
|
}
|