yorck-mcp 0.1.0 → 0.1.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.
- package/README.md +11 -2
- package/package.json +1 -1
- package/src/cli.ts +44 -11
- package/src/index.ts +84 -9
- package/src/tool-registration.ts +80 -0
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
|
-
-
|
|
7
|
-
-
|
|
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.
|
|
26
|
+
|
|
18
27
|
## Booking
|
|
19
28
|
|
|
20
29
|
Dry run:
|
package/package.json
CHANGED
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
|
|
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,45 @@ 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
|
-
|
|
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
|
-
|
|
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, seat: { row: seat.rowLabel, seat: seat.seatId } },
|
|
397
|
+
manualBookingUrl: sessionBookingUrl(env, showtime),
|
|
398
|
+
filmPageUrl: showtime.url,
|
|
399
|
+
note: "No Yorck account or Unlimited card needed. This does not reserve the seat; use the Yorck page to book manually, or run book-best with credentials for a dry-run/booking flow.",
|
|
400
|
+
next: {
|
|
401
|
+
seatMap: `npx yorck-mcp seat-map ${showtime.sessionId} --out seat-map.svg`,
|
|
402
|
+
calendar: `npx yorck-mcp calendar ${showtime.sessionId} ${showtime.slug} --out movie.ics`,
|
|
403
|
+
unlimitedDryRun: `YORCK_EMAIL=... YORCK_PASSWORD=... YORCK_UNLIMITED_CARD=... npx yorck-mcp book-best --q ${JSON.stringify(showtime.film)} --when tonight`,
|
|
404
|
+
},
|
|
405
|
+
seatPlanSummary,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function commandBookBest(env: Env, args: ParsedArgs) {
|
|
410
|
+
const picked = await pickBestShowtime(env, args);
|
|
411
|
+
if (!picked) throw new Error("No bookable showtime with available seats found");
|
|
412
|
+
const { showtime, seat, seatPlanSummary } = picked;
|
|
413
|
+
const booking = await bookSpecific(env, args, {
|
|
414
|
+
sessionId: showtime.sessionId,
|
|
415
|
+
cinemaSlug: showtime.cinemaSlug,
|
|
416
|
+
rowLabel: seat.rowLabel,
|
|
417
|
+
seatId: seat.seatId,
|
|
418
|
+
showtime,
|
|
419
|
+
});
|
|
420
|
+
printJson({ selected: { ...showtime, seat: { row: seat.rowLabel, seat: seat.seatId } }, booking, seatPlanSummary });
|
|
392
421
|
}
|
|
393
422
|
|
|
394
423
|
async function commandMe(env: Env, args: ParsedArgs) {
|
|
@@ -447,6 +476,7 @@ Usage:
|
|
|
447
476
|
yorck coming-soon
|
|
448
477
|
yorck seat-map <session-id> [--out seat-map.svg]
|
|
449
478
|
yorck calendar <session-id> <film-slug> [--out event.ics]
|
|
479
|
+
yorck plan --q <film> --when tonight --after 18:00
|
|
450
480
|
yorck me [--email you@example.com --password ...]
|
|
451
481
|
yorck book <session-id> --cinema <slug> --row <row> --seat <seat> [--commit --yes]
|
|
452
482
|
yorck book-best --q <film> --when tonight --after 18:00 [--commit --yes]
|
|
@@ -484,6 +514,9 @@ async function main() {
|
|
|
484
514
|
case "calendar":
|
|
485
515
|
case "ics":
|
|
486
516
|
return commandCalendar(env, args);
|
|
517
|
+
case "plan":
|
|
518
|
+
case "pick":
|
|
519
|
+
return commandPlan(env, args);
|
|
487
520
|
case "me":
|
|
488
521
|
return commandMe(env, args);
|
|
489
522
|
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.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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)
|
package/src/tool-registration.ts
CHANGED
|
@@ -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,32 @@ 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, seat: { row: seat.rowLabel, seat: seat.seatId } },
|
|
214
|
+
manualBookingUrl: sessionBookingUrl(env, showtime),
|
|
215
|
+
filmPageUrl: showtime.url,
|
|
216
|
+
note: "This is a read-only plan and does not reserve the seat. Use Yorck manually, or use book_session with local credentials for the Unlimited booking flow.",
|
|
217
|
+
rows,
|
|
218
|
+
}, null, 2),
|
|
219
|
+
}],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
|
|
144
224
|
server.tool(
|
|
145
225
|
"add_to_calendar",
|
|
146
226
|
"Generate an .ics calendar event for a public Yorck showtime.",
|