yorck-mcp 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  CLI and MCP tools for Yorck Berlin cinema.
4
4
 
5
5
  - Public search, film lookup, cinemas, seat maps, and ICS calendar files need no account.
6
- - Booking is local and opt-in. It needs your own Yorck account email, password, and Yorck Unlimited card number.
7
- - Real booking is never the default. Dry run validates Unlimited pricing and releases the seat hold.
6
+ - Manual planning is usable without Yorck Unlimited. It returns showtimes, seat maps, calendar files, and a checkout link.
7
+ - Automated booking is local and opt-in. Today it needs your own Yorck account email, password, and Yorck Unlimited card number.
8
+ - Real automated booking is never the default. Dry run validates Unlimited pricing and releases the seat hold.
8
9
 
9
10
  ## CLI
10
11
 
@@ -15,6 +16,14 @@ npx yorck-mcp seat-map 1007-30456 --out seat-map.svg
15
16
  npx yorck-mcp calendar 1007-30456 the-devil-wears-prada-2 --out movie.ics
16
17
  ```
17
18
 
19
+ Plan a manual booking, no account needed:
20
+
21
+ ```bash
22
+ npx yorck-mcp plan --q "devil wears prada" --when tonight --after 18:00
23
+ ```
24
+
25
+ This returns a checkout seat-selection link, for example `https://www.yorck.de/en/checkout/seats?sessionid=...`, plus the matching film, cinema, time, and a seat-plan summary. The manual flow lets the user choose seats on Yorck's page.
26
+
18
27
  ## Booking
19
28
 
20
29
  Dry run:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yorck-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "scripts": {
package/src/cli.ts CHANGED
@@ -161,6 +161,10 @@ function shortTime(iso: string): string {
161
161
  return m ? `${m[1]} ${m[2]}` : iso;
162
162
  }
163
163
 
164
+ function sessionBookingUrl(env: Env, showtime: Showtime): string {
165
+ return `${env.YORCK_BASE.replace(/\/$/, "")}/en/checkout/seats?sessionid=${encodeURIComponent(showtime.sessionId)}`;
166
+ }
167
+
164
168
  function printShowtimes(showtimes: Showtime[], limit = 20) {
165
169
  if (!showtimes.length) {
166
170
  console.log("No matching showtimes.");
@@ -367,7 +371,7 @@ async function commandBook(env: Env, args: ParsedArgs) {
367
371
  printJson(await bookSpecific(env, args, { sessionId, cinemaSlug, rowLabel, seatId }));
368
372
  }
369
373
 
370
- async function commandBookBest(env: Env, args: ParsedArgs) {
374
+ async function pickBestShowtime(env: Env, args: ParsedArgs) {
371
375
  const search = await searchShowtimes(env, filters(args));
372
376
  const candidates = rankShowtimes(search).slice(0, intOpt(args, "candidates") ?? 8);
373
377
  for (const showtime of candidates) {
@@ -375,20 +379,46 @@ async function commandBookBest(env: Env, args: ParsedArgs) {
375
379
  const { rows } = await seatPlanForSession(env, showtime.sessionId);
376
380
  const seat = pickSeat(rows);
377
381
  if (!seat) continue;
378
- const booking = await bookSpecific(env, args, {
379
- sessionId: showtime.sessionId,
380
- cinemaSlug: showtime.cinemaSlug,
381
- rowLabel: seat.rowLabel,
382
- seatId: seat.seatId,
383
- showtime,
384
- });
385
- printJson({ selected: { ...showtime, seat: { row: seat.rowLabel, seat: seat.seatId } }, booking });
386
- return;
382
+ return { showtime, seat, seatPlanSummary: rows };
387
383
  } catch (error) {
388
384
  if (bool(args, "verbose")) console.error(`Skipping ${showtime.sessionId}: ${String(error)}`);
389
385
  }
390
386
  }
391
- throw new Error("No bookable showtime with available seats found");
387
+ return null;
388
+ }
389
+
390
+ async function commandPlan(env: Env, args: ParsedArgs) {
391
+ const picked = await pickBestShowtime(env, args);
392
+ if (!picked) throw new Error("No matching showtime with available seats found");
393
+ const { showtime, seat, seatPlanSummary } = picked;
394
+ printJson({
395
+ ok: true,
396
+ selected: showtime,
397
+ manualBookingUrl: sessionBookingUrl(env, showtime),
398
+ filmPageUrl: showtime.url,
399
+ apiSeatForAutomation: { row: seat.rowLabel, seatId: seat.seatId },
400
+ note: "No Yorck account or Unlimited card needed. This does not reserve the seat; use the Yorck checkout page to choose and book manually. apiSeatForAutomation is for the booking API, not necessarily the visible seat label on Yorck's website.",
401
+ next: {
402
+ seatMap: `npx yorck-mcp seat-map ${showtime.sessionId} --out seat-map.svg`,
403
+ calendar: `npx yorck-mcp calendar ${showtime.sessionId} ${showtime.slug} --out movie.ics`,
404
+ unlimitedDryRun: `YORCK_EMAIL=... YORCK_PASSWORD=... YORCK_UNLIMITED_CARD=... npx yorck-mcp book-best --q ${JSON.stringify(showtime.film)} --when tonight`,
405
+ },
406
+ seatPlanSummary,
407
+ });
408
+ }
409
+
410
+ async function commandBookBest(env: Env, args: ParsedArgs) {
411
+ const picked = await pickBestShowtime(env, args);
412
+ if (!picked) throw new Error("No bookable showtime with available seats found");
413
+ const { showtime, seat, seatPlanSummary } = picked;
414
+ const booking = await bookSpecific(env, args, {
415
+ sessionId: showtime.sessionId,
416
+ cinemaSlug: showtime.cinemaSlug,
417
+ rowLabel: seat.rowLabel,
418
+ seatId: seat.seatId,
419
+ showtime,
420
+ });
421
+ printJson({ selected: { ...showtime, seat: { row: seat.rowLabel, seat: seat.seatId } }, booking, seatPlanSummary });
392
422
  }
393
423
 
394
424
  async function commandMe(env: Env, args: ParsedArgs) {
@@ -447,6 +477,7 @@ Usage:
447
477
  yorck coming-soon
448
478
  yorck seat-map <session-id> [--out seat-map.svg]
449
479
  yorck calendar <session-id> <film-slug> [--out event.ics]
480
+ yorck plan --q <film> --when tonight --after 18:00
450
481
  yorck me [--email you@example.com --password ...]
451
482
  yorck book <session-id> --cinema <slug> --row <row> --seat <seat> [--commit --yes]
452
483
  yorck book-best --q <film> --when tonight --after 18:00 [--commit --yes]
@@ -484,6 +515,9 @@ async function main() {
484
515
  case "calendar":
485
516
  case "ics":
486
517
  return commandCalendar(env, args);
518
+ case "plan":
519
+ case "pick":
520
+ return commandPlan(env, args);
487
521
  case "me":
488
522
  return commandMe(env, args);
489
523
  case "book":
package/src/index.ts CHANGED
@@ -35,15 +35,90 @@ function requireAuth(c: { req: { raw: Request }; env: Env; text: (body: string,
35
35
  }
36
36
 
37
37
  app.get("/", (c) =>
38
- c.text(
39
- [
40
- "yorck-mcp Worker.",
41
- "Public read-only MCP: /public/mcp (Streamable HTTP), /public/sse (SSE).",
42
- "Private authenticated MCP with booking: /mcp, /sse.",
43
- "Public REST: /v1/films, /v1/films/search, /v1/coming-soon, /v1/cinemas, /v1/seat-plan/:sessionId, /v1/seat-map/:sessionId.",
44
- "Private REST: /v1/book/*, /v1/me, /v1/browser-*.",
45
- ].join("\n")
46
- )
38
+ c.html(`<!doctype html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8" />
42
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
43
+ <title>yorck-mcp</title>
44
+ <style>
45
+ :root { color-scheme: light dark; --bg: #f7f6f2; --fg: #191919; --muted: #66615b; --card: #fff; --line: #e4e0d8; --accent: #d62828; }
46
+ @media (prefers-color-scheme: dark) { :root { --bg: #121212; --fg: #f6f2ea; --muted: #bbb2a8; --card: #1c1c1c; --line: #333; } }
47
+ body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: radial-gradient(circle at 20% 0%, rgba(214,40,40,.12), transparent 34rem), var(--bg); color: var(--fg); }
48
+ main { max-width: 980px; margin: 0 auto; padding: 64px 22px; }
49
+ h1 { font-size: clamp(44px, 7vw, 86px); letter-spacing: -.07em; line-height: .92; margin: 0 0 18px; }
50
+ h2 { font-size: 24px; letter-spacing: -.03em; margin-top: 0; }
51
+ p { color: var(--muted); font-size: 18px; line-height: 1.6; }
52
+ .pill { display: inline-flex; align-items: center; gap: 8px; border: 1px solid var(--line); background: color-mix(in srgb, var(--card) 84%, transparent); border-radius: 999px; padding: 8px 12px; color: var(--muted); font-weight: 700; font-size: 14px; }
53
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; margin: 32px 0; }
54
+ .card { background: color-mix(in srgb, var(--card) 92%, transparent); border: 1px solid var(--line); border-radius: 28px; padding: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.08); }
55
+ code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
56
+ pre { overflow-x: auto; background: #111; color: #f6f2ea; border-radius: 20px; padding: 18px; font-size: 14px; line-height: 1.55; }
57
+ a { color: var(--accent); font-weight: 800; }
58
+ ul { color: var(--muted); line-height: 1.8; padding-left: 20px; }
59
+ </style>
60
+ </head>
61
+ <body>
62
+ <main>
63
+ <span class="pill">Yorck Berlin cinema · CLI + MCP</span>
64
+ <h1>search showtimes, render seats, optionally book with your own Unlimited account.</h1>
65
+ <p><strong>yorck-mcp</strong> is a public read-only MCP server, a terminal CLI, and a local bookable stdio MCP for agents. Public search, seat maps, calendar files, and direct checkout links need no account. Automated booking stays local and currently requires your Yorck email, password, and Unlimited card number.</p>
66
+
67
+ <div class="grid">
68
+ <section class="card">
69
+ <h2>Public MCP</h2>
70
+ <p>Use the hosted read-only endpoint in any MCP client.</p>
71
+ <pre>{
72
+ "mcpServers": {
73
+ "yorck": {
74
+ "command": "npx",
75
+ "args": ["-y", "mcp-remote", "https://yorck-mcp.isiklimahir.workers.dev/public/mcp"]
76
+ }
77
+ }
78
+ }</pre>
79
+ </section>
80
+ <section class="card">
81
+ <h2>CLI</h2>
82
+ <p>Use it directly from a terminal.</p>
83
+ <pre>npx -y yorck-mcp whats-on --when tonight --after 18:00
84
+ npx -y yorck-mcp search "devil wears prada"
85
+ npx -y yorck-mcp seat-map 1007-30456 --out seat-map.svg
86
+ npx -y yorck-mcp plan --q "devil wears prada" --when tonight --after 18:00</pre>
87
+ </section>
88
+ <section class="card">
89
+ <h2>Bookable local MCP</h2>
90
+ <p>Run a local stdio MCP with your credentials. Dry-run is the safe default, real automated booking requires explicit confirmation. Without Unlimited, use the returned checkout link and finish paid checkout on Yorck's site.</p>
91
+ <pre>{
92
+ "mcpServers": {
93
+ "yorck": {
94
+ "command": "npx",
95
+ "args": ["-y", "yorck-mcp", "mcp-stdio"],
96
+ "env": {
97
+ "YORCK_EMAIL": "you@example.com",
98
+ "YORCK_PASSWORD": "...",
99
+ "YORCK_UNLIMITED_CARD": "..."
100
+ }
101
+ }
102
+ }
103
+ }</pre>
104
+ </section>
105
+ </div>
106
+
107
+ <section class="card">
108
+ <h2>Tools</h2>
109
+ <ul>
110
+ <li><code>whats_on</code>, search showtimes by date, time, cinema, format, genre, and title</li>
111
+ <li><code>find_film</code>, <code>showtimes</code>, <code>coming_soon</code>, <code>cinemas</code></li>
112
+ <li><code>seat_map</code>, render a session seat plan as SVG and return available row and seat IDs</li>
113
+ <li><code>pick_showtime</code>, pick a public session and return a checkout seat-selection link</li>
114
+ <li><code>add_to_calendar</code>, generate an ICS file or downloadable calendar URL</li>
115
+ <li><code>book_session</code> and <code>cancel_booking</code>, private/local only</li>
116
+ </ul>
117
+ <p>Docs and post: <a href="https://mahir.is/posts/yorck-mcp-public-cinema-agent">mahir.is/posts/yorck-mcp-public-cinema-agent</a></p>
118
+ </section>
119
+ </main>
120
+ </body>
121
+ </html>`)
47
122
  );
48
123
 
49
124
  // REST endpoints (also handy for quick curl tests)
@@ -48,6 +48,60 @@ function publicBaseUrl(env: Env): string {
48
48
  return (env.PUBLIC_BASE_URL || "https://yorck-mcp.isiklimahir.workers.dev").replace(/\/$/, "");
49
49
  }
50
50
 
51
+ function sessionBookingUrl(env: Env, showtime: Showtime): string {
52
+ return `${env.YORCK_BASE.replace(/\/$/, "")}/en/checkout/seats?sessionid=${encodeURIComponent(showtime.sessionId)}`;
53
+ }
54
+
55
+ function rankShowtimes(showtimes: Showtime[]) {
56
+ const priority: Record<string, number> = { OmeU: 4, OV: 3, OmU: 2, DF: 1 };
57
+ return [...showtimes].sort((a, b) => {
58
+ const fp = (priority[b.format] ?? 0) - (priority[a.format] ?? 0);
59
+ if (fp !== 0) return fp;
60
+ return a.start.localeCompare(b.start);
61
+ });
62
+ }
63
+
64
+ function seatRowsFromPlan(plan: Awaited<ReturnType<typeof getSeatPlan>>) {
65
+ return (plan.SeatLayoutData.Areas[0]?.Rows ?? []).map((row) => ({
66
+ physicalName: String(row.PhysicalName ?? row.RowIndexZeroBased),
67
+ rowIndex: row.RowIndexZeroBased,
68
+ availableIds: (row.Seats ?? []).filter((seat) => seat.Status === 0).map((seat) => seat.Id),
69
+ }));
70
+ }
71
+
72
+ function pickSeat(rows: Array<{ physicalName: string; rowIndex: number; availableIds: Array<string | number> }>) {
73
+ const availableRows = rows.filter((row) => row.availableIds?.length);
74
+ if (!availableRows.length) return null;
75
+ const minIndex = Math.min(...availableRows.map((r) => Number(r.rowIndex) || 0));
76
+ const maxIndex = Math.max(...availableRows.map((r) => Number(r.rowIndex) || 0));
77
+ const idealRow = minIndex + (maxIndex - minIndex) * 0.45;
78
+ const row = [...availableRows].sort((a, b) => {
79
+ const aDist = Math.abs((Number(a.rowIndex) || 0) - idealRow);
80
+ const bDist = Math.abs((Number(b.rowIndex) || 0) - idealRow);
81
+ if (aDist !== bDist) return aDist - bDist;
82
+ return (b.availableIds.length || 0) - (a.availableIds.length || 0);
83
+ })[0]!;
84
+ const seats = [...row.availableIds].sort((a, b) => Number(a) - Number(b));
85
+ return { rowLabel: String(row.physicalName), seatId: String(seats[Math.floor((seats.length - 1) / 2)]) };
86
+ }
87
+
88
+ async function pickBestShowtime(env: Env, filters: Parameters<typeof searchShowtimes>[1], candidatesLimit = 8) {
89
+ const showtimes = rankShowtimes(await searchShowtimes(env, filters)).slice(0, candidatesLimit);
90
+ for (const showtime of showtimes) {
91
+ const [cinemaVistaId, sessionNum] = showtime.sessionId.split("-");
92
+ if (!cinemaVistaId || !sessionNum) continue;
93
+ try {
94
+ const plan = await getSeatPlan(env, cinemaVistaId, sessionNum);
95
+ const rows = seatRowsFromPlan(plan);
96
+ const seat = pickSeat(rows);
97
+ if (seat) return { showtime, seat, rows };
98
+ } catch {
99
+ // Try the next candidate.
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
51
105
  export function registerPublicTools(server: McpServer, env: Env) {
52
106
  server.tool(
53
107
  "whats_on",
@@ -141,6 +195,33 @@ export function registerPublicTools(server: McpServer, env: Env) {
141
195
  }
142
196
  );
143
197
 
198
+ server.tool(
199
+ "pick_showtime",
200
+ "Read-only helper that searches showtimes, chooses a reasonable available seat, and returns a manual booking URL. No account, reservation, or payment needed.",
201
+ {
202
+ ...FilmFiltersSchema,
203
+ candidates: z.number().int().min(1).max(20).optional().describe("How many top search results to inspect for available seats. Default 8."),
204
+ },
205
+ async ({ candidates, ...args }) => {
206
+ const picked = await pickBestShowtime(env, args, candidates ?? 8);
207
+ if (!picked) throw new Error("No matching showtime with available seats found");
208
+ const { showtime, seat, rows } = picked;
209
+ return {
210
+ content: [{
211
+ type: "text" as const,
212
+ text: JSON.stringify({
213
+ selected: showtime,
214
+ manualBookingUrl: sessionBookingUrl(env, showtime),
215
+ filmPageUrl: showtime.url,
216
+ apiSeatForAutomation: { row: seat.rowLabel, seatId: seat.seatId },
217
+ note: "This is a read-only plan and does not reserve the seat. Use Yorck checkout manually, or use book_session with local credentials for the Unlimited booking flow. apiSeatForAutomation is for the booking API, not necessarily the visible seat label on Yorck's website.",
218
+ rows,
219
+ }, null, 2),
220
+ }],
221
+ };
222
+ }
223
+ );
224
+
144
225
  server.tool(
145
226
  "add_to_calendar",
146
227
  "Generate an .ics calendar event for a public Yorck showtime.",