@chrischall/eventbrite-mcp 0.0.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 +34 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.mcp.json +11 -0
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/bundle.js +40401 -0
- package/dist/client.js +76 -0
- package/dist/discovery.js +530 -0
- package/dist/eventbrite-auth.js +18 -0
- package/dist/index.js +31 -0
- package/dist/tools/account.js +170 -0
- package/dist/tools/discovery.js +126 -0
- package/dist/tools/events.js +131 -0
- package/dist/tools/lookup.js +96 -0
- package/dist/tools/params.js +39 -0
- package/dist/transport-fetchproxy.js +57 -0
- package/dist/transport.js +5 -0
- package/dist/version.js +1 -0
- package/package.json +72 -0
- package/server.json +29 -0
- package/skills/eventbrite/SKILL.md +70 -0
- package/skills/eventbrite/references/discovery-api.md +113 -0
- package/skills/eventbrite/references/token-api.md +102 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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; silently skip if dotenv is unavailable (e.g. mcpb
|
|
5
|
+
// bundle). `loadDotenvSafely` swallows a missing dotenv module and never lets
|
|
6
|
+
// .env override a host-provided value.
|
|
7
|
+
// The try/catch guards the Cloudflare Worker runtime, where `import.meta.url`
|
|
8
|
+
// is undefined and `fileURLToPath(undefined)` would throw at module init
|
|
9
|
+
// (Worker startup validation) — there is no filesystem / .env to load there.
|
|
10
|
+
try {
|
|
11
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
await loadDotenvSafely({ path: join(dir, '..', '.env'), override: false });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
/* non-Node runtime (Workers): no .env to load */
|
|
16
|
+
}
|
|
17
|
+
const BASE_URL = 'https://www.eventbriteapi.com/v3';
|
|
18
|
+
const SERVICE_NAME = 'Eventbrite';
|
|
19
|
+
/**
|
|
20
|
+
* Client for the documented Eventbrite API (`eventbriteapi.com/v3`) — the
|
|
21
|
+
* server-side-reachable surface: your identity, your orders/tickets, the
|
|
22
|
+
* organizations you belong to, and any event by id. Public event *search*
|
|
23
|
+
* does not exist here (removed in 2019); that lives on the WAF-walled
|
|
24
|
+
* consumer surface handled by `discovery.ts` via the browser bridge.
|
|
25
|
+
*/
|
|
26
|
+
export class EventbriteClient {
|
|
27
|
+
token;
|
|
28
|
+
configError;
|
|
29
|
+
api;
|
|
30
|
+
/**
|
|
31
|
+
* Defer the config error so the server can still start (and respond to the
|
|
32
|
+
* host's install-time smoke test) when EVENTBRITE_TOKEN isn't set yet.
|
|
33
|
+
* Tool calls re-raise the error at request time.
|
|
34
|
+
*
|
|
35
|
+
* Optional constructor seam: the hosted Cloudflare connector builds one
|
|
36
|
+
* client per request with that user's `token` injected. The stdio path
|
|
37
|
+
* passes no options, so the token resolves from the environment.
|
|
38
|
+
* The constructor is PURE (no I/O, no randomness) — it is run in Worker
|
|
39
|
+
* global scope via the module singleton below.
|
|
40
|
+
*/
|
|
41
|
+
constructor(opts) {
|
|
42
|
+
const token = opts?.token ?? readEnvVar('EVENTBRITE_TOKEN');
|
|
43
|
+
if (!token) {
|
|
44
|
+
this.token = null;
|
|
45
|
+
this.configError = new Error('EVENTBRITE_TOKEN environment variable is required (create a private token at https://www.eventbrite.com/platform/api-keys)');
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
this.token = token;
|
|
49
|
+
this.configError = null;
|
|
50
|
+
}
|
|
51
|
+
this.api = createApiClient({
|
|
52
|
+
baseUrl: BASE_URL,
|
|
53
|
+
getToken: () => this.requireToken(),
|
|
54
|
+
serviceName: SERVICE_NAME,
|
|
55
|
+
retry: { count: 1, delayMs: 2000 },
|
|
56
|
+
timeout: 30_000,
|
|
57
|
+
onUnauthorized: () => new Error('EVENTBRITE_TOKEN is invalid or missing (eventbrite.com/platform/api-keys)'),
|
|
58
|
+
onRateLimited: () => new Error('Rate limited by the Eventbrite API (default 2,000 calls/hour)'),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
requireToken() {
|
|
62
|
+
if (this.configError)
|
|
63
|
+
throw this.configError;
|
|
64
|
+
return this.token;
|
|
65
|
+
}
|
|
66
|
+
async request(method, path, body) {
|
|
67
|
+
return this.api.fetchJson(method, path, body !== undefined ? { body } : {});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Module-level singleton shared by every tool module. Constructing it here (not
|
|
72
|
+
* in `index.ts`) keeps the deferred-config-error pattern: the server boots and
|
|
73
|
+
* answers the host's install-time tools/list smoke test even when the token is
|
|
74
|
+
* absent — the error only surfaces on the first request.
|
|
75
|
+
*/
|
|
76
|
+
export const client = new EventbriteClient();
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// Client for Eventbrite's consumer discovery surface
|
|
2
|
+
// (`www.eventbrite.com/api/v3/destination/…` + the SSR `/d/…` browse pages).
|
|
3
|
+
// This surface is WAF-blocked for server-side clients, so every call goes
|
|
4
|
+
// through an EventbriteTransport (the fetchproxy bridge in production).
|
|
5
|
+
//
|
|
6
|
+
// Request shapes were captured live from the site's own network traffic
|
|
7
|
+
// (2026-07-30, discover web app v10.14.65) — see docs/EVENTBRITE-API.md.
|
|
8
|
+
import { McpToolError, BotWallError, parseCookieHeader } from '@chrischall/mcp-utils';
|
|
9
|
+
/** Browse pages are plain SSR HTML — reachable server-side, no bridge needed. */
|
|
10
|
+
const BROWSE_ORIGIN = 'https://www.eventbrite.com';
|
|
11
|
+
const BROWSE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36';
|
|
12
|
+
/** Match the API client's timeout so a stalled connection cannot hang a call. */
|
|
13
|
+
const BROWSE_TIMEOUT_MS = 30_000;
|
|
14
|
+
/** A 404 is a real answer; a 200 is only usable if it actually carries a placeId. */
|
|
15
|
+
function isUsableBrowsePage(result) {
|
|
16
|
+
if (result.status === 404)
|
|
17
|
+
return true;
|
|
18
|
+
if (result.status !== 200)
|
|
19
|
+
return false;
|
|
20
|
+
return typeof result.body === 'string' && PLACE_ID_RE.test(result.body);
|
|
21
|
+
}
|
|
22
|
+
/** `fetch` with an AbortController deadline; called directly for workerd. */
|
|
23
|
+
async function fetchWithTimeout(url, ms) {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
26
|
+
try {
|
|
27
|
+
return await fetch(url, {
|
|
28
|
+
headers: { 'User-Agent': BROWSE_UA, Accept: 'text/html' },
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export const PLACE_ID_RE = /"placeId":"(\d+)"/;
|
|
37
|
+
/** Expansions the site itself requests for search results / event batches. */
|
|
38
|
+
export const DEFAULT_EVENT_EXPANSIONS = [
|
|
39
|
+
'primary_venue',
|
|
40
|
+
'image',
|
|
41
|
+
'ticket_availability',
|
|
42
|
+
'event_sales_status',
|
|
43
|
+
'primary_organizer',
|
|
44
|
+
];
|
|
45
|
+
const US_STATES = {
|
|
46
|
+
alabama: 'al', alaska: 'ak', arizona: 'az', arkansas: 'ar', california: 'ca',
|
|
47
|
+
colorado: 'co', connecticut: 'ct', delaware: 'de', florida: 'fl', georgia: 'ga',
|
|
48
|
+
hawaii: 'hi', idaho: 'id', illinois: 'il', indiana: 'in', iowa: 'ia',
|
|
49
|
+
kansas: 'ks', kentucky: 'ky', louisiana: 'la', maine: 'me', maryland: 'md',
|
|
50
|
+
massachusetts: 'ma', michigan: 'mi', minnesota: 'mn', mississippi: 'ms',
|
|
51
|
+
missouri: 'mo', montana: 'mt', nebraska: 'ne', nevada: 'nv',
|
|
52
|
+
'new-hampshire': 'nh', 'new-jersey': 'nj', 'new-mexico': 'nm', 'new-york': 'ny',
|
|
53
|
+
'north-carolina': 'nc', 'north-dakota': 'nd', ohio: 'oh', oklahoma: 'ok',
|
|
54
|
+
oregon: 'or', pennsylvania: 'pa', 'rhode-island': 'ri', 'south-carolina': 'sc',
|
|
55
|
+
'south-dakota': 'sd', tennessee: 'tn', texas: 'tx', utah: 'ut', vermont: 'vt',
|
|
56
|
+
virginia: 'va', washington: 'wa', 'west-virginia': 'wv', wisconsin: 'wi',
|
|
57
|
+
wyoming: 'wy', 'district-of-columbia': 'dc',
|
|
58
|
+
};
|
|
59
|
+
const US_STATE_CODES = new Set(Object.values(US_STATES));
|
|
60
|
+
/** Country-level markers for the US: valid English, but never a valid slug. */
|
|
61
|
+
const US_COUNTRY_MARKERS = new Set(['us', 'usa', 'united-states', 'united-states-of-america']);
|
|
62
|
+
/** Common abbreviations for countries whose slug uses the full name. */
|
|
63
|
+
const COUNTRY_ALIASES = {
|
|
64
|
+
uk: 'united-kingdom',
|
|
65
|
+
gb: 'united-kingdom',
|
|
66
|
+
'great-britain': 'united-kingdom',
|
|
67
|
+
uae: 'united-arab-emirates',
|
|
68
|
+
nz: 'new-zealand',
|
|
69
|
+
roi: 'ireland',
|
|
70
|
+
};
|
|
71
|
+
/** Lowercase, drop punctuation, collapse whitespace to single hyphens. */
|
|
72
|
+
function normalizeSegment(s) {
|
|
73
|
+
return s
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/[.'’]/g, '')
|
|
76
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
77
|
+
.replace(/^-+|-+$/g, '');
|
|
78
|
+
}
|
|
79
|
+
export const SLUG_RE = /^[a-z0-9-]+--[a-z0-9-]+$/;
|
|
80
|
+
/**
|
|
81
|
+
* Turn a human location into candidate browse slugs for `resolvePlace`.
|
|
82
|
+
*
|
|
83
|
+
* Eventbrite browse slugs are `<region>--<city>`, where region is a US state
|
|
84
|
+
* abbreviation (`nc--charlotte`) or a country name (`germany--berlin`). An
|
|
85
|
+
* already-valid slug is returned untouched.
|
|
86
|
+
*
|
|
87
|
+
* A bare city with no qualifier returns NO candidates on purpose: "Springfield"
|
|
88
|
+
* or "Portland" cannot be resolved to a region without guessing, and guessing
|
|
89
|
+
* an id is exactly what this repo forbids. Callers should ask instead.
|
|
90
|
+
*/
|
|
91
|
+
export function slugCandidates(input) {
|
|
92
|
+
const raw = input.trim();
|
|
93
|
+
if (SLUG_RE.test(raw))
|
|
94
|
+
return [raw];
|
|
95
|
+
const parts = raw.split(',').map((p) => p.trim()).filter(Boolean);
|
|
96
|
+
if (parts.length < 2)
|
|
97
|
+
return [];
|
|
98
|
+
const city = normalizeSegment(parts[0]);
|
|
99
|
+
if (!city)
|
|
100
|
+
return [];
|
|
101
|
+
// Each comma-separated part after the city is its own candidate qualifier.
|
|
102
|
+
// Folding them together would produce 'nc-usa--charlotte' for
|
|
103
|
+
// 'Charlotte, NC, USA'; resolveLocation already tries candidates in turn.
|
|
104
|
+
const candidates = [];
|
|
105
|
+
const push = (slug) => {
|
|
106
|
+
if (!candidates.includes(slug))
|
|
107
|
+
candidates.push(slug);
|
|
108
|
+
};
|
|
109
|
+
for (const part of parts.slice(1)) {
|
|
110
|
+
const q = normalizeSegment(part);
|
|
111
|
+
if (!q)
|
|
112
|
+
continue;
|
|
113
|
+
if (US_STATE_CODES.has(q)) {
|
|
114
|
+
push(`${q}--${city}`);
|
|
115
|
+
}
|
|
116
|
+
else if (US_STATES[q]) {
|
|
117
|
+
push(`${US_STATES[q]}--${city}`);
|
|
118
|
+
}
|
|
119
|
+
else if (US_COUNTRY_MARKERS.has(q)) {
|
|
120
|
+
// US browse slugs are state-scoped — 'usa--charlotte' can never resolve.
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// Anything else is a country (germany--berlin, ireland--dublin).
|
|
125
|
+
const alias = COUNTRY_ALIASES[q];
|
|
126
|
+
if (alias)
|
|
127
|
+
push(`${alias}--${city}`);
|
|
128
|
+
push(`${q}--${city}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return candidates;
|
|
132
|
+
}
|
|
133
|
+
/** Build the search POST body exactly as the site sends it. */
|
|
134
|
+
export function buildSearchBody(params) {
|
|
135
|
+
const es = {
|
|
136
|
+
dedup: true,
|
|
137
|
+
page: params.page ?? 1,
|
|
138
|
+
page_size: params.pageSize ?? 20,
|
|
139
|
+
};
|
|
140
|
+
if (params.q)
|
|
141
|
+
es.q = params.q;
|
|
142
|
+
if (params.placeId)
|
|
143
|
+
es.places = [params.placeId];
|
|
144
|
+
// The site always includes 'current_future' and appends the keyword.
|
|
145
|
+
const dates = ['current_future'];
|
|
146
|
+
if (params.dateKeyword)
|
|
147
|
+
dates.push(params.dateKeyword);
|
|
148
|
+
es.dates = dates;
|
|
149
|
+
if (params.dateRangeFrom || params.dateRangeTo) {
|
|
150
|
+
es.date_range = {
|
|
151
|
+
...(params.dateRangeFrom ? { from: params.dateRangeFrom } : {}),
|
|
152
|
+
...(params.dateRangeTo ? { to: params.dateRangeTo } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const tags = [];
|
|
156
|
+
if (params.categoryId)
|
|
157
|
+
tags.push(`EventbriteCategory/${params.categoryId}`);
|
|
158
|
+
if (params.subcategoryId)
|
|
159
|
+
tags.push(`EventbriteSubCategory/${params.subcategoryId}`);
|
|
160
|
+
if (params.formatId)
|
|
161
|
+
tags.push(`EventbriteFormat/${params.formatId}`);
|
|
162
|
+
if (tags.length > 0)
|
|
163
|
+
es.tags = tags;
|
|
164
|
+
if (params.price)
|
|
165
|
+
es.price = params.price;
|
|
166
|
+
if (params.onlineEventsOnly !== undefined)
|
|
167
|
+
es.online_events_only = params.onlineEventsOnly;
|
|
168
|
+
// Facet buckets (places_borough / places_neighborhood). The site sends these
|
|
169
|
+
// alongside a normal query; omitted entirely when not asked for.
|
|
170
|
+
if (params.aggs && params.aggs.length > 0)
|
|
171
|
+
es.aggs = [...params.aggs];
|
|
172
|
+
return {
|
|
173
|
+
browse_surface: 'search',
|
|
174
|
+
event_search: es,
|
|
175
|
+
'expand.destination_event': [...DEFAULT_EVENT_EXPANSIONS],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Project a fat destination event to the compact browse shape. */
|
|
179
|
+
export function toCompactEvent(ev) {
|
|
180
|
+
const venue = ev.primary_venue;
|
|
181
|
+
const address = venue?.address;
|
|
182
|
+
const organizer = ev.primary_organizer;
|
|
183
|
+
const avail = ev.ticket_availability;
|
|
184
|
+
const out = {
|
|
185
|
+
id: String(ev.id ?? ''),
|
|
186
|
+
name: String(ev.name ?? ''),
|
|
187
|
+
};
|
|
188
|
+
if (typeof ev.start_date === 'string')
|
|
189
|
+
out.start_date = ev.start_date;
|
|
190
|
+
if (typeof ev.start_time === 'string')
|
|
191
|
+
out.start_time = ev.start_time;
|
|
192
|
+
if (typeof ev.timezone === 'string')
|
|
193
|
+
out.timezone = ev.timezone;
|
|
194
|
+
if (typeof venue?.name === 'string')
|
|
195
|
+
out.venue = venue.name;
|
|
196
|
+
if (typeof address?.city === 'string')
|
|
197
|
+
out.city = address.city;
|
|
198
|
+
if (typeof ev.is_online_event === 'boolean')
|
|
199
|
+
out.is_online_event = ev.is_online_event;
|
|
200
|
+
if (typeof avail?.is_free === 'boolean')
|
|
201
|
+
out.is_free = avail.is_free;
|
|
202
|
+
if (typeof avail?.is_sold_out === 'boolean')
|
|
203
|
+
out.is_sold_out = avail.is_sold_out;
|
|
204
|
+
if (typeof organizer?.name === 'string')
|
|
205
|
+
out.organizer = organizer.name;
|
|
206
|
+
if (typeof ev.summary === 'string')
|
|
207
|
+
out.summary = ev.summary;
|
|
208
|
+
if (typeof ev.url === 'string')
|
|
209
|
+
out.url = ev.url;
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
export class DiscoveryClient {
|
|
213
|
+
transport;
|
|
214
|
+
api;
|
|
215
|
+
csrfToken = null;
|
|
216
|
+
/**
|
|
217
|
+
* Two routes to the same data, preferred in order:
|
|
218
|
+
*
|
|
219
|
+
* 1. `api` — `POST eventbriteapi.com/v3/destination/search/` with a bearer
|
|
220
|
+
* token. Verified live 2026-07-30: works with a private OR public token,
|
|
221
|
+
* no WAF, no CSRF, no cookies. This is the default because it needs no
|
|
222
|
+
* browser and therefore works inside a Worker.
|
|
223
|
+
* 2. `transport` — the fetchproxy bridge through a signed-in tab. Retained as
|
|
224
|
+
* a fallback for when no token is configured, or the API route refuses.
|
|
225
|
+
*
|
|
226
|
+
* Either may be null; at least one must be present for a call to succeed.
|
|
227
|
+
*/
|
|
228
|
+
constructor(transport, api = null) {
|
|
229
|
+
this.transport = transport;
|
|
230
|
+
this.api = api;
|
|
231
|
+
}
|
|
232
|
+
noRoute(what) {
|
|
233
|
+
return new McpToolError(`No route available for ${what}.`, {
|
|
234
|
+
hint: 'Set EVENTBRITE_TOKEN, or pair the fetchproxy bridge and keep an eventbrite.com tab open.',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Read (and cache) the `csrftoken` cookie from the signed-in tab. Django
|
|
239
|
+
* requires the search POST's `X-CSRFToken` header to match this cookie;
|
|
240
|
+
* GETs are exempt. `force` refreshes the cache after a CSRF rejection.
|
|
241
|
+
*/
|
|
242
|
+
async ensureCsrf(force = false) {
|
|
243
|
+
if (this.csrfToken && !force)
|
|
244
|
+
return this.csrfToken;
|
|
245
|
+
if (!this.transport)
|
|
246
|
+
throw this.noRoute('the CSRF cookie read');
|
|
247
|
+
const raw = await this.transport.readCookies(['csrftoken']);
|
|
248
|
+
const token = parseCookieHeader(raw)['csrftoken'];
|
|
249
|
+
if (!token) {
|
|
250
|
+
throw new McpToolError('Could not read the csrftoken cookie from the browser tab.', {
|
|
251
|
+
hint: 'Open (or refresh) a www.eventbrite.com tab in the paired browser, then retry.',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
this.csrfToken = token;
|
|
255
|
+
return token;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Guard a destination-API response: a 2xx whose body did not parse as JSON
|
|
259
|
+
* is the WAF interstitial leaking through; 403 usually means the wall or a
|
|
260
|
+
* stale session.
|
|
261
|
+
*/
|
|
262
|
+
guard(data, result, path) {
|
|
263
|
+
if (result.status >= 200 && result.status < 300) {
|
|
264
|
+
if (data === null) {
|
|
265
|
+
throw new BotWallError(path, 60, { vendor: undefined });
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (result.status === 403) {
|
|
270
|
+
throw new McpToolError(`Eventbrite returned 403 for ${path}.`, {
|
|
271
|
+
hint: 'Refresh a signed-in www.eventbrite.com tab in the paired browser, then retry.',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const detail = typeof result.body === 'string' ? result.body.slice(0, 300) : '';
|
|
275
|
+
throw new McpToolError(`Eventbrite returned HTTP ${result.status} for ${path}. ${detail}`);
|
|
276
|
+
}
|
|
277
|
+
/** Public event search — token API first, bridge as fallback. */
|
|
278
|
+
async search(params) {
|
|
279
|
+
const body = buildSearchBody(params);
|
|
280
|
+
if (this.api) {
|
|
281
|
+
try {
|
|
282
|
+
return await this.api.request('POST', '/destination/search/', body);
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
// No bridge to fall back to — surface the API's own error.
|
|
286
|
+
if (!this.transport)
|
|
287
|
+
throw e;
|
|
288
|
+
console.error('[eventbrite-mcp] token-API search failed, falling back to the browser bridge:', e instanceof Error ? e.message : String(e));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const transport = this.transport;
|
|
292
|
+
if (!transport)
|
|
293
|
+
throw this.noRoute('event search');
|
|
294
|
+
const attempt = async (csrf) => transport.requestJson('POST', '/api/v3/destination/search/', {
|
|
295
|
+
headers: {
|
|
296
|
+
'Content-Type': 'application/json',
|
|
297
|
+
'X-CSRFToken': csrf,
|
|
298
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
299
|
+
},
|
|
300
|
+
body: JSON.stringify(body),
|
|
301
|
+
});
|
|
302
|
+
let { data, result } = await attempt(await this.ensureCsrf());
|
|
303
|
+
// One retry with a fresh cookie on a CSRF rejection (401 ACCESS_DENIED).
|
|
304
|
+
if (result.status === 401 && typeof result.body === 'string' && result.body.includes('CSRF')) {
|
|
305
|
+
({ data, result } = await attempt(await this.ensureCsrf(true)));
|
|
306
|
+
}
|
|
307
|
+
this.guard(data, result, '/api/v3/destination/search/');
|
|
308
|
+
return data;
|
|
309
|
+
}
|
|
310
|
+
/** Batch event detail by id — token API first, bridge as fallback. */
|
|
311
|
+
async eventsByIds(eventIds, expand = [...DEFAULT_EVENT_EXPANSIONS]) {
|
|
312
|
+
if (this.api) {
|
|
313
|
+
try {
|
|
314
|
+
// Verified live 2026-07-30: the documented HOST also serves the
|
|
315
|
+
// DESTINATION batch endpoint, and that is deliberately the one used.
|
|
316
|
+
//
|
|
317
|
+
// `/events/?event_ids=` works too but returns the documented shape
|
|
318
|
+
// (`name: {text, html}`, `start: {utc}`, no primary_venue), while the
|
|
319
|
+
// bridge fallback and eb_search_events both return the destination
|
|
320
|
+
// shape (`name` string, `start_date`/`start_time`, `primary_venue`).
|
|
321
|
+
// Since eb_event_details hands the payload back raw, using /events/
|
|
322
|
+
// would flip the caller's parse target depending on which route ran.
|
|
323
|
+
// /destination/events/ keeps one shape on both routes and accepts the
|
|
324
|
+
// destination expansion names natively, so nothing needs translating.
|
|
325
|
+
const params = new URLSearchParams({ event_ids: eventIds.join(',') });
|
|
326
|
+
if (expand.length > 0)
|
|
327
|
+
params.set('expand', expand.join(','));
|
|
328
|
+
return await this.api.request('GET', `/destination/events/?${params}`);
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
if (!this.transport)
|
|
332
|
+
throw e;
|
|
333
|
+
console.error('[eventbrite-mcp] token-API event batch failed, falling back to the browser bridge:', e instanceof Error ? e.message : String(e));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!this.transport)
|
|
337
|
+
throw this.noRoute('event detail');
|
|
338
|
+
const path = `/api/v3/destination/events/?event_ids=${eventIds.join(',')}&expand=${expand.join(',')}`;
|
|
339
|
+
const { data, result } = await this.transport.requestJson('GET', path, {
|
|
340
|
+
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
341
|
+
});
|
|
342
|
+
this.guard(data, result, '/api/v3/destination/events/');
|
|
343
|
+
return data;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Fetch an SSR browse page. Verified live 2026-07-30: `/d/<slug>/events/`
|
|
347
|
+
* answers 200 with the full ~800 KB page to a plain server-side GET carrying
|
|
348
|
+
* a browser User-Agent — no bridge, no session.
|
|
349
|
+
*
|
|
350
|
+
* The bridge is the fallback for ANY unusable outcome, not just a thrown
|
|
351
|
+
* error: the WAF blocks with a 403/429 or a 200 interstitial, and those
|
|
352
|
+
* arrive as perfectly ordinary responses. Falling back only on a throw would
|
|
353
|
+
* hard-fail exactly the case the bridge exists to rescue.
|
|
354
|
+
*
|
|
355
|
+
* `fetch` is called directly rather than stored: a detached reference throws
|
|
356
|
+
* `Illegal invocation` in workerd (fleet gotcha).
|
|
357
|
+
*/
|
|
358
|
+
async fetchBrowsePage(path) {
|
|
359
|
+
const url = `${BROWSE_ORIGIN}${path}`;
|
|
360
|
+
let direct = null;
|
|
361
|
+
let failure = '';
|
|
362
|
+
try {
|
|
363
|
+
const res = await fetchWithTimeout(url, BROWSE_TIMEOUT_MS);
|
|
364
|
+
direct = { status: res.status, body: await res.text(), url };
|
|
365
|
+
if (isUsableBrowsePage(direct))
|
|
366
|
+
return direct;
|
|
367
|
+
failure = `HTTP ${direct.status} without a placeId (WAF block or drift)`;
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
failure = e instanceof Error ? e.message : String(e);
|
|
371
|
+
}
|
|
372
|
+
if (!this.transport) {
|
|
373
|
+
// Nothing to fall back to: return the direct response so resolvePlace
|
|
374
|
+
// renders its own 404 / bot-wall error, or rethrow if there was none.
|
|
375
|
+
if (direct)
|
|
376
|
+
return direct;
|
|
377
|
+
throw new McpToolError(`Could not fetch ${url}: ${failure}`);
|
|
378
|
+
}
|
|
379
|
+
console.error(`[eventbrite-mcp] direct browse fetch unusable (${failure}); falling back to the browser bridge`);
|
|
380
|
+
return this.transport.fetch({ path, method: 'GET' });
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Resolve a browse slug (`nc--charlotte`, `germany--berlin`) to Eventbrite's
|
|
384
|
+
* internal place id by fetching the SSR browse page and extracting
|
|
385
|
+
* `"placeId"` from the embedded `__SERVER_DATA__` (verified against raw
|
|
386
|
+
* fetched bytes — the SSR page carries exactly one occurrence).
|
|
387
|
+
*/
|
|
388
|
+
async resolvePlace(slug) {
|
|
389
|
+
const path = `/d/${slug}/events/`;
|
|
390
|
+
const result = await this.fetchBrowsePage(path);
|
|
391
|
+
if (result.status === 404) {
|
|
392
|
+
throw new McpToolError(`No Eventbrite browse page for slug '${slug}'.`, {
|
|
393
|
+
hint: "Slug format is '<state>--<city>' for US (nc--charlotte) or '<country>--<city>' elsewhere (germany--berlin).",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
const body = typeof result.body === 'string' ? result.body : '';
|
|
397
|
+
const m = body.match(PLACE_ID_RE);
|
|
398
|
+
if (result.status !== 200 || !m) {
|
|
399
|
+
throw new BotWallError(path, 60);
|
|
400
|
+
}
|
|
401
|
+
const nameMatch = body.match(/"currentPlace":"([^"]+)"/);
|
|
402
|
+
const place = {
|
|
403
|
+
placeId: m[1],
|
|
404
|
+
name: nameMatch ? nameMatch[1] : null,
|
|
405
|
+
slug,
|
|
406
|
+
};
|
|
407
|
+
// The same fetch already carries curated browse shelves and place context;
|
|
408
|
+
// harvesting them is free. Parse the ~800 KB payload ONCE and read both out
|
|
409
|
+
// of it. Best-effort — drift yields nothing extra.
|
|
410
|
+
const data = parseServerData(body);
|
|
411
|
+
if (data) {
|
|
412
|
+
if (typeof data.region === 'string')
|
|
413
|
+
place.region = data.region;
|
|
414
|
+
if (typeof data.country === 'string')
|
|
415
|
+
place.country = data.country;
|
|
416
|
+
const shelves = shelvesFromServerData(data);
|
|
417
|
+
if (shelves)
|
|
418
|
+
place.shelves = shelves;
|
|
419
|
+
}
|
|
420
|
+
return place;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Resolve a human location ('Charlotte, NC', 'Berlin, Germany') or a raw
|
|
424
|
+
* browse slug to a place id, trying each candidate slug in turn.
|
|
425
|
+
*/
|
|
426
|
+
async resolveLocation(input) {
|
|
427
|
+
const candidates = slugCandidates(input);
|
|
428
|
+
if (candidates.length === 0) {
|
|
429
|
+
throw new McpToolError(`Could not turn '${input}' into an Eventbrite browse slug.`, {
|
|
430
|
+
hint: "Include a state or country — 'Charlotte, NC' or 'Berlin, Germany' — or pass a slug directly ('nc--charlotte').",
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
let lastError;
|
|
434
|
+
for (const slug of candidates) {
|
|
435
|
+
try {
|
|
436
|
+
return await this.resolvePlace(slug);
|
|
437
|
+
}
|
|
438
|
+
catch (e) {
|
|
439
|
+
lastError = e;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
throw lastError instanceof Error
|
|
443
|
+
? lastError
|
|
444
|
+
: new McpToolError(`Could not resolve '${input}'.`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Pull the themed browse shelves out of an SSR browse page's `__SERVER_DATA__`.
|
|
449
|
+
*
|
|
450
|
+
* Verified against live bytes (2026-07-30, /d/nc--charlotte/events/): the events
|
|
451
|
+
* live in `buckets[]`, each `{key, name, events[]}` — NOT in `search_data`
|
|
452
|
+
* (which does not exist) and not in `reactQueryData` (which is a string). These
|
|
453
|
+
* are curated shelves, not a page of search results, so they are surfaced as
|
|
454
|
+
* such rather than pretending to be search output.
|
|
455
|
+
*
|
|
456
|
+
* Shelf events carry `primary_venue` but no `ticket_availability` or expanded
|
|
457
|
+
* organizer, so those CompactEvent fields stay undefined here.
|
|
458
|
+
*
|
|
459
|
+
* Guarded throughout: any drift yields `undefined` and the caller simply
|
|
460
|
+
* searches normally.
|
|
461
|
+
*/
|
|
462
|
+
export function extractBrowseShelves(body) {
|
|
463
|
+
const data = parseServerData(body);
|
|
464
|
+
return data ? shelvesFromServerData(data) : undefined;
|
|
465
|
+
}
|
|
466
|
+
/** Project already-parsed `__SERVER_DATA__` into shelves (no re-parse). */
|
|
467
|
+
export function shelvesFromServerData(data) {
|
|
468
|
+
const buckets = data.buckets;
|
|
469
|
+
if (!Array.isArray(buckets))
|
|
470
|
+
return undefined;
|
|
471
|
+
const shelves = [];
|
|
472
|
+
for (const b of buckets) {
|
|
473
|
+
if (!b || typeof b !== 'object')
|
|
474
|
+
continue;
|
|
475
|
+
const bucket = b;
|
|
476
|
+
const events = bucket.events;
|
|
477
|
+
if (!Array.isArray(events) || events.length === 0)
|
|
478
|
+
continue;
|
|
479
|
+
shelves.push({
|
|
480
|
+
key: String(bucket.key ?? ''),
|
|
481
|
+
name: String(bucket.name ?? ''),
|
|
482
|
+
events: events.map((e) => toCompactEvent(e)),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return shelves.length > 0 ? shelves : undefined;
|
|
486
|
+
}
|
|
487
|
+
/** Brace-match `__SERVER_DATA__`'s object and parse it. Returns null on drift. */
|
|
488
|
+
export function parseServerData(body) {
|
|
489
|
+
const marker = body.indexOf('__SERVER_DATA__');
|
|
490
|
+
if (marker === -1)
|
|
491
|
+
return null;
|
|
492
|
+
const start = body.indexOf('{', marker);
|
|
493
|
+
if (start === -1)
|
|
494
|
+
return null;
|
|
495
|
+
let depth = 0;
|
|
496
|
+
let inString = false;
|
|
497
|
+
let escaped = false;
|
|
498
|
+
let end = -1;
|
|
499
|
+
for (let i = start; i < body.length; i++) {
|
|
500
|
+
const ch = body[i];
|
|
501
|
+
if (inString) {
|
|
502
|
+
if (escaped)
|
|
503
|
+
escaped = false;
|
|
504
|
+
else if (ch === '\\')
|
|
505
|
+
escaped = true;
|
|
506
|
+
else if (ch === '"')
|
|
507
|
+
inString = false;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (ch === '"')
|
|
511
|
+
inString = true;
|
|
512
|
+
else if (ch === '{')
|
|
513
|
+
depth++;
|
|
514
|
+
else if (ch === '}') {
|
|
515
|
+
depth--;
|
|
516
|
+
if (depth === 0) {
|
|
517
|
+
end = i + 1;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (end === -1)
|
|
523
|
+
return null;
|
|
524
|
+
try {
|
|
525
|
+
return JSON.parse(body.slice(start, end));
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { EventbriteClient } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* `ConnectorAuth` for the Eventbrite remote connector: the login page collects
|
|
4
|
+
* the user's personal Eventbrite token (eventbrite.com/platform/api-keys),
|
|
5
|
+
* verifies it against the current-user endpoint (a bad token throws, which
|
|
6
|
+
* the connector surfaces back on the login page), and stores `{ token }`.
|
|
7
|
+
*/
|
|
8
|
+
export const eventbriteAuth = {
|
|
9
|
+
service: 'Eventbrite',
|
|
10
|
+
accent: '#F05537',
|
|
11
|
+
privacyNote: 'Your Eventbrite private token is stored encrypted and used only to call the Eventbrite API on your behalf.',
|
|
12
|
+
fields: [{ name: 'token', label: 'Eventbrite private token', type: 'password' }],
|
|
13
|
+
async login(fields) {
|
|
14
|
+
const client = new EventbriteClient({ token: fields.token });
|
|
15
|
+
await client.request('GET', '/users/me/');
|
|
16
|
+
return { token: fields.token };
|
|
17
|
+
},
|
|
18
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runMcp, readPortEnv } from '@chrischall/mcp-utils';
|
|
3
|
+
import { VERSION } from './version.js';
|
|
4
|
+
import { client } from './client.js';
|
|
5
|
+
import { DiscoveryClient } from './discovery.js';
|
|
6
|
+
import { FetchproxyTransport } from './transport-fetchproxy.js';
|
|
7
|
+
import { registerAccountTools } from './tools/account.js';
|
|
8
|
+
import { registerEventTools } from './tools/events.js';
|
|
9
|
+
import { registerLookupTools } from './tools/lookup.js';
|
|
10
|
+
import { registerDiscoveryTools } from './tools/discovery.js';
|
|
11
|
+
// Two surfaces, one server:
|
|
12
|
+
// - `client` (documented API, bearer EVENTBRITE_TOKEN) — deferred-config
|
|
13
|
+
// singleton from ./client.js; account + event tools.
|
|
14
|
+
// - `discovery` (public event discovery). Verified live 2026-07-30: the
|
|
15
|
+
// documented host serves the consumer search at
|
|
16
|
+
// POST /destination/search/ with a plain bearer token — no WAF, no CSRF,
|
|
17
|
+
// no browser. That is now the primary route, so discovery works in a
|
|
18
|
+
// Worker too. The fetchproxy bridge (port 37149, bound lazily) is kept as
|
|
19
|
+
// a FALLBACK for when no token is configured or the API route refuses.
|
|
20
|
+
const transport = new FetchproxyTransport({
|
|
21
|
+
version: VERSION,
|
|
22
|
+
port: readPortEnv('EVENTBRITE_WS_PORT', 37_149),
|
|
23
|
+
});
|
|
24
|
+
const discovery = new DiscoveryClient(transport, client);
|
|
25
|
+
await runMcp({
|
|
26
|
+
name: 'eventbrite-mcp',
|
|
27
|
+
version: VERSION,
|
|
28
|
+
deps: { client, discovery, transport },
|
|
29
|
+
banner: '[eventbrite-mcp] This project was developed and is maintained by AI (Claude Fable 5). Use at your own discretion.',
|
|
30
|
+
tools: [registerAccountTools, registerEventTools, registerLookupTools, registerDiscoveryTools],
|
|
31
|
+
});
|