setlist-mcp 0.5.1 → 0.6.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.
@@ -0,0 +1,57 @@
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
+ // Declare the apex `setlist.fm` scope (so a re-render with no scope change
41
+ // never needs re-approval), but read cookies from the `www` subdomain — the
42
+ // session cookies are host-only on www.setlist.fm, and www is a subdomain of
43
+ // the approved apex, so chrome.cookies.get sees JSESSIONID without a re-pair.
44
+ const session = await withTimeout(bootstrap({
45
+ serverName: 'setlist-mcp',
46
+ version: VERSION,
47
+ domains: ['setlist.fm'],
48
+ storageSubdomain: 'www',
49
+ declare: { cookies: SESSION_COOKIE_KEYS, localStorage: [], sessionStorage: [], captureHeaders: [] },
50
+ }), BOOTSTRAP_TIMEOUT_MS);
51
+ const cookies = session.cookies ?? {};
52
+ const header = SESSION_COOKIE_KEYS.filter((k) => cookies[k]).map((k) => `${k}=${cookies[k]}`).join('; ');
53
+ if (!cookies.JSESSIONID && !cookies.RememberMeCookie) {
54
+ 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.');
55
+ }
56
+ return header;
57
+ }
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
+ }
@@ -14,6 +14,25 @@ function songCountOf(s) {
14
14
  const sets = Array.isArray(s.sets?.set) ? s.sets.set : [];
15
15
  return sets.reduce((n, set) => n + (Array.isArray(set?.song) ? set.song.length : 0), 0);
16
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
+ }
17
36
  // Loosen punctuation/format variants for a fuzzy retry: drop quotes, turn + / &
18
37
  // into "and", drop stray periods, collapse whitespace. (Dan + Shay, "Weird Al"
19
38
  // Yankovic, DJ Pee .Wee.)
@@ -69,7 +88,46 @@ function pickBest(list, city, venue) {
69
88
  // Best location/song score wins; prefer a populated setlist on ties via songCount.
70
89
  return [...list].sort((a, b) => score(b, city, venue) - score(a, city, venue) || songCountOf(b) - songCountOf(a))[0];
71
90
  }
72
- async function resolveOne(req, c) {
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) {
73
131
  const filters = {
74
132
  date: isoToDmy(c.date),
75
133
  ...(c.city ? { cityName: c.city } : {}),
@@ -94,7 +152,7 @@ async function resolveOne(req, c) {
94
152
  if (!best)
95
153
  return { input: c, match: null, alternatives: 0 };
96
154
  const songCount = songCountOf(best);
97
- return {
155
+ const result = {
98
156
  input: c,
99
157
  match: {
100
158
  setlistId: best.id,
@@ -109,6 +167,13 @@ async function resolveOne(req, c) {
109
167
  },
110
168
  alternatives: list.length - 1,
111
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;
112
177
  }
113
178
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
114
179
  /**
@@ -123,6 +188,7 @@ export async function resolveConcerts(concerts, deps = {}) {
123
188
  const now = deps.now ?? Date.now;
124
189
  const paceMs = deps.paceMs ?? PACE_MS;
125
190
  const budgetMs = deps.budgetMs ?? BUDGET_MS;
191
+ const tourFallback = deps.tourFallback ?? true;
126
192
  // Gate every upstream call to at least `paceMs` apart (the first runs immediately).
127
193
  let lastCallAt = 0;
128
194
  const req = async (method, path, opts) => {
@@ -141,7 +207,7 @@ export async function resolveConcerts(concerts, deps = {}) {
141
207
  results.push({ input: c, match: null, alternatives: 0, pending: true });
142
208
  continue;
143
209
  }
144
- results.push(await resolveOne(req, c));
210
+ results.push(await resolveOne(req, c, tourFallback));
145
211
  }
146
212
  return results;
147
213
  }
@@ -150,7 +216,15 @@ export function summarizeResults(results) {
150
216
  const pending = results.filter((r) => r.pending).length;
151
217
  const matched = results.filter((r) => r.match).length;
152
218
  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 };
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
+ };
154
228
  const payload = { results, summary };
155
229
  if (pending > 0) {
156
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).`;
@@ -159,7 +233,7 @@ export function summarizeResults(results) {
159
233
  }
160
234
  export function registerResolveTools(server) {
161
235
  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." +
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." +
163
237
  ATTRIBUTION_NOTE,
164
238
  annotations: { readOnlyHint: true },
165
239
  inputSchema: {
@@ -173,9 +247,13 @@ export function registerResolveTools(server) {
173
247
  .min(1)
174
248
  .max(MAX_BATCH)
175
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.'),
176
254
  },
177
- }, async ({ concerts }) => {
178
- const results = await resolveConcerts(concerts);
255
+ }, async ({ concerts, tourFallback }) => {
256
+ const results = await resolveConcerts(concerts, { tourFallback });
179
257
  return textResult(summarizeResults(results));
180
258
  });
181
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.5.1'; // x-release-please-version
6
+ export const VERSION = '0.6.1'; // x-release-please-version
@@ -0,0 +1,100 @@
1
+ import { dirname, join } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import { loadDotenvSafely, readEnvVar, createApiClient, messageOf } 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 RETRY_5XX = 3; // www.setlist.fm intermittently returns 500/502/503 from its gateway
8
+ const RETRY_DELAY_MS = 1200;
9
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
+ // Retry transient gateway errors (502/503/504); createApiClient only retries 429.
11
+ async function retryOn5xx(fn) {
12
+ let lastErr;
13
+ for (let attempt = 0; attempt <= RETRY_5XX; attempt++) {
14
+ try {
15
+ return await fn();
16
+ }
17
+ catch (err) {
18
+ lastErr = err;
19
+ if (attempt < RETRY_5XX && /\b50[0234]\b/.test(messageOf(err))) {
20
+ await sleep(RETRY_DELAY_MS);
21
+ continue;
22
+ }
23
+ throw err;
24
+ }
25
+ }
26
+ throw lastErr;
27
+ }
28
+ const BASE_URL = 'https://www.setlist.fm';
29
+ const SERVICE_NAME = 'setlist.fm (web)';
30
+ const REQUEST_TIMEOUT_MS = 20_000;
31
+ // A browser-like UA; the bare default can trip the site's bot heuristics.
32
+ 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';
33
+ /**
34
+ * Authenticated client for the setlist.fm **website** (not the public REST API).
35
+ * The site has no JSON API for user actions — it's server-rendered HTML plus
36
+ * Apache Wicket stateful AJAX, authenticated by the logged-in session cookie.
37
+ *
38
+ * Auth resolves in order: `SETLIST_SESSION_COOKIE` (env) → a fetchproxy
39
+ * `read_cookies` bootstrap from the signed-in browser tab (lazy, optional) →
40
+ * a deferred config error at request time. Kept entirely separate from the
41
+ * api-key `SetlistClient` so the public-API tools never depend on a session.
42
+ */
43
+ export class SetlistWebClient {
44
+ cookie;
45
+ configError;
46
+ api;
47
+ constructor() {
48
+ this.cookie = readEnvVar('SETLIST_SESSION_COOKIE') ?? null;
49
+ 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.');
50
+ this.api = createApiClient({
51
+ baseUrl: BASE_URL,
52
+ serviceName: SERVICE_NAME,
53
+ timeout: REQUEST_TIMEOUT_MS,
54
+ retry: { count: 1, delayMs: 2000 },
55
+ baseHeaders: { 'User-Agent': USER_AGENT },
56
+ });
57
+ }
58
+ /**
59
+ * Resolve the session cookie: `SETLIST_SESSION_COOKIE` (env) first, else a
60
+ * one-time fetchproxy `read_cookies` grab from the signed-in tab (lazy-imported
61
+ * so the env path never loads the bridge), else the deferred config error.
62
+ * The grabbed cookie is cached on the instance for the process lifetime.
63
+ */
64
+ async requireCookie() {
65
+ if (this.cookie)
66
+ return this.cookie;
67
+ const { grabSessionCookie } = await import('./fetchproxy-cookie.js');
68
+ const grabbed = await grabSessionCookie();
69
+ if (grabbed) {
70
+ this.cookie = grabbed;
71
+ return grabbed;
72
+ }
73
+ throw this.configError;
74
+ }
75
+ /** GET a page as HTML, authenticated. `path` is appended to the www base URL. */
76
+ async fetchPage(path) {
77
+ const cookie = await this.requireCookie();
78
+ return retryOn5xx(() => this.api.fetchHtml('GET', path, { headers: { Cookie: cookie } }));
79
+ }
80
+ /**
81
+ * Replay an Apache Wicket AJAX behavior GET (e.g. the attendance toggle).
82
+ * `ajaxPath` is the per-render URL parsed from a page's `wicketAjaxGet(...)`;
83
+ * `baseUrl` is the rendering page's path (no leading slash) for the
84
+ * `Wicket-Ajax-BaseURL` header. Returns the `<ajax-response>` XML body.
85
+ */
86
+ async wicketAjaxGet(ajaxPath, baseUrl) {
87
+ const cookie = await this.requireCookie();
88
+ return retryOn5xx(() => this.api.fetchHtml('GET', ajaxPath, {
89
+ headers: {
90
+ Cookie: cookie,
91
+ 'Wicket-Ajax': 'true',
92
+ 'Wicket-Ajax-BaseURL': baseUrl,
93
+ 'X-Requested-With': 'XMLHttpRequest',
94
+ Accept: 'text/xml, text/javascript, application/xml, text/html, */*',
95
+ },
96
+ }));
97
+ }
98
+ }
99
+ /** Module-level singleton (deferred-config: missing session surfaces at request time). */
100
+ export const webClient = new SetlistWebClient();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "setlist-mcp",
3
- "version": "0.5.1",
3
+ "version": "0.6.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>",
@@ -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.1",
47
+ "@fetchproxy/server": "^1.3.1",
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.5.1",
9
+ "version": "0.6.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "setlist-mcp",
14
- "version": "0.5.1",
14
+ "version": "0.6.1",
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
  }