yorck-mcp 0.1.1 → 0.1.3
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 +18 -1
- package/package.json +1 -1
- package/src/cli.ts +54 -4
- package/src/index.ts +23 -87
- package/src/landing.ts +82 -0
- package/src/skill-content.ts +131 -0
- package/src/tool-registration.ts +31 -3
- package/src/yorck/seats.ts +35 -1
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ CLI and MCP tools for Yorck Berlin cinema.
|
|
|
13
13
|
npx yorck-mcp whats-on --when tonight --after 18:00 --cinemas passage,rollberg
|
|
14
14
|
npx yorck-mcp search "devil wears prada"
|
|
15
15
|
npx yorck-mcp seat-map 1007-30456 --out seat-map.svg
|
|
16
|
+
npx yorck-mcp seat-map-html 1007-30456 --out seat-map.html
|
|
16
17
|
npx yorck-mcp calendar 1007-30456 the-devil-wears-prada-2 --out movie.ics
|
|
17
18
|
```
|
|
18
19
|
|
|
@@ -22,7 +23,7 @@ Plan a manual booking, no account needed:
|
|
|
22
23
|
npx yorck-mcp plan --q "devil wears prada" --when tonight --after 18:00
|
|
23
24
|
```
|
|
24
25
|
|
|
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
|
+
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
27
|
|
|
27
28
|
## Booking
|
|
28
29
|
|
|
@@ -46,6 +47,22 @@ npx yorck-mcp book-best --q "devil wears prada" --when tonight --after 18:00 --c
|
|
|
46
47
|
|
|
47
48
|
You do not need to provide a separate member ID. The Yorck login response includes it when the account has one, and the booking flow uses it internally.
|
|
48
49
|
|
|
50
|
+
## Skill install
|
|
51
|
+
|
|
52
|
+
Install a Claude Code skill that teaches the agent which Yorck tool to use in each environment:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx yorck-mcp install-skill --target claude
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or from the hosted page:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
curl -fsSL https://yorck-mcp.isiklimahir.workers.dev/install.sh | bash
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The skill includes guidance for Claude Web remote connectors, Claude Code local MCP, inline HTML seat-map fallback, and confirmation-gated booking.
|
|
65
|
+
|
|
49
66
|
## MCP
|
|
50
67
|
|
|
51
68
|
Read-only remote MCP:
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
|
-
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
5
|
import type { Env, Showtime } from "./types.ts";
|
|
6
6
|
import { findFilmsByQuery, getComingSoon, searchShowtimes, type FilmFilters } from "./yorck/films.ts";
|
|
7
7
|
import { getCinemas, getCinemaBySlug } from "./yorck/cinemas.ts";
|
|
8
|
-
import { findSeatByRowAndId, getSeatPlan, renderSeatPlanSvg } from "./yorck/seats.ts";
|
|
8
|
+
import { findSeatByRowAndId, getSeatPlan, renderSeatPlanHtml, renderSeatPlanSvg } from "./yorck/seats.ts";
|
|
9
9
|
import { cancelOrder, commitOrder, reserveUnlimited } from "./yorck/booking.ts";
|
|
10
10
|
import { showtimeToIcs } from "./lib/ics.ts";
|
|
11
11
|
import { whoAmI } from "./yorck/auth.ts";
|
|
12
|
+
import { YORCK_MOVIE_AGENT_SKILL } from "./skill-content.ts";
|
|
12
13
|
|
|
13
14
|
const PUBLIC_MCP_URL = "https://yorck-mcp.isiklimahir.workers.dev/public/mcp";
|
|
14
15
|
|
|
@@ -342,6 +343,19 @@ async function commandSeatMap(env: Env, args: ParsedArgs) {
|
|
|
342
343
|
printJson({ sessionId, cinema: cinema?.name, rows, svg: out ? undefined : svg });
|
|
343
344
|
}
|
|
344
345
|
|
|
346
|
+
async function commandSeatMapHtml(env: Env, args: ParsedArgs) {
|
|
347
|
+
const sessionId = args._[0] || str(args, "sessionId");
|
|
348
|
+
if (!sessionId) throw new Error("usage: yorck seat-map-html <session-id> [--out seat-map.html]");
|
|
349
|
+
const { plan, cinema, rows } = await seatPlanForSession(env, sessionId);
|
|
350
|
+
const html = renderSeatPlanHtml(plan, { title: cinema?.name ?? "Yorck", subtitle: `Session ${sessionId}` });
|
|
351
|
+
const out = str(args, "out");
|
|
352
|
+
if (out) {
|
|
353
|
+
await writeFile(out, html, "utf8");
|
|
354
|
+
console.log(`Wrote ${out}`);
|
|
355
|
+
}
|
|
356
|
+
printJson({ sessionId, cinema: cinema?.name, rows, html: out ? undefined : html });
|
|
357
|
+
}
|
|
358
|
+
|
|
345
359
|
async function commandCalendar(env: Env, args: ParsedArgs) {
|
|
346
360
|
const sessionId = args._[0] || str(args, "sessionId");
|
|
347
361
|
const slug = args._[1] || str(args, "slug");
|
|
@@ -393,10 +407,11 @@ async function commandPlan(env: Env, args: ParsedArgs) {
|
|
|
393
407
|
const { showtime, seat, seatPlanSummary } = picked;
|
|
394
408
|
printJson({
|
|
395
409
|
ok: true,
|
|
396
|
-
selected:
|
|
410
|
+
selected: showtime,
|
|
397
411
|
manualBookingUrl: sessionBookingUrl(env, showtime),
|
|
398
412
|
filmPageUrl: showtime.url,
|
|
399
|
-
|
|
413
|
+
apiSeatForAutomation: { row: seat.rowLabel, seatId: seat.seatId },
|
|
414
|
+
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.",
|
|
400
415
|
next: {
|
|
401
416
|
seatMap: `npx yorck-mcp seat-map ${showtime.sessionId} --out seat-map.svg`,
|
|
402
417
|
calendar: `npx yorck-mcp calendar ${showtime.sessionId} ${showtime.slug} --out movie.ics`,
|
|
@@ -425,6 +440,32 @@ async function commandMe(env: Env, args: ParsedArgs) {
|
|
|
425
440
|
printJson(await whoAmI(env));
|
|
426
441
|
}
|
|
427
442
|
|
|
443
|
+
async function commandSkill(args: ParsedArgs) {
|
|
444
|
+
const out = str(args, "out");
|
|
445
|
+
if (out) {
|
|
446
|
+
await writeFile(out, YORCK_MOVIE_AGENT_SKILL, "utf8");
|
|
447
|
+
console.log(`Wrote ${out}`);
|
|
448
|
+
} else {
|
|
449
|
+
process.stdout.write(YORCK_MOVIE_AGENT_SKILL);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function commandInstallSkill(args: ParsedArgs) {
|
|
454
|
+
const target = (str(args, "target") || "claude").toLowerCase();
|
|
455
|
+
const home = process.env.HOME || process.cwd();
|
|
456
|
+
const destinations: string[] = [];
|
|
457
|
+
if (target === "claude" || target === "both") destinations.push(`${home}/.claude/skills/yorck-movie-agent`);
|
|
458
|
+
if (target === "pi" || target === "both") destinations.push(`${home}/.pi/agent/skills/yorck-movie-agent`);
|
|
459
|
+
const customDest = str(args, "dest");
|
|
460
|
+
if (customDest) destinations.splice(0, destinations.length, customDest.replace(/^~/, home));
|
|
461
|
+
if (!destinations.length) throw new Error("usage: yorck install-skill [--target claude|pi|both] [--dest path]");
|
|
462
|
+
for (const dest of destinations) {
|
|
463
|
+
await mkdir(dest, { recursive: true });
|
|
464
|
+
await writeFile(`${dest}/SKILL.md`, YORCK_MOVIE_AGENT_SKILL, "utf8");
|
|
465
|
+
console.log(`Installed skill to ${dest}/SKILL.md`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
428
469
|
function commandMcpConfig(args: ParsedArgs) {
|
|
429
470
|
const privateUrl = str(args, "url") || process.env.YORCK_MCP_URL || "https://yorck-mcp.isiklimahir.workers.dev/mcp";
|
|
430
471
|
if (bool(args, "remotePrivate")) {
|
|
@@ -475,6 +516,7 @@ Usage:
|
|
|
475
516
|
yorck cinemas
|
|
476
517
|
yorck coming-soon
|
|
477
518
|
yorck seat-map <session-id> [--out seat-map.svg]
|
|
519
|
+
yorck seat-map-html <session-id> [--out seat-map.html]
|
|
478
520
|
yorck calendar <session-id> <film-slug> [--out event.ics]
|
|
479
521
|
yorck plan --q <film> --when tonight --after 18:00
|
|
480
522
|
yorck me [--email you@example.com --password ...]
|
|
@@ -482,6 +524,8 @@ Usage:
|
|
|
482
524
|
yorck book-best --q <film> --when tonight --after 18:00 [--commit --yes]
|
|
483
525
|
yorck mcp-stdio
|
|
484
526
|
yorck mcp-config [--private]
|
|
527
|
+
yorck skill [--out SKILL.md]
|
|
528
|
+
yorck install-skill [--target claude|pi|both]
|
|
485
529
|
|
|
486
530
|
Public commands need no account. Booking commands need Yorck credentials and an Unlimited card number.
|
|
487
531
|
Use env vars instead of flags for secrets: YORCK_EMAIL, YORCK_PASSWORD, YORCK_UNLIMITED_CARD.
|
|
@@ -511,6 +555,8 @@ async function main() {
|
|
|
511
555
|
return printJson(await getComingSoon(env));
|
|
512
556
|
case "seat-map":
|
|
513
557
|
return commandSeatMap(env, args);
|
|
558
|
+
case "seat-map-html":
|
|
559
|
+
return commandSeatMapHtml(env, args);
|
|
514
560
|
case "calendar":
|
|
515
561
|
case "ics":
|
|
516
562
|
return commandCalendar(env, args);
|
|
@@ -525,6 +571,10 @@ async function main() {
|
|
|
525
571
|
return commandBookBest(env, args);
|
|
526
572
|
case "mcp-config":
|
|
527
573
|
return commandMcpConfig(args);
|
|
574
|
+
case "skill":
|
|
575
|
+
return commandSkill(args);
|
|
576
|
+
case "install-skill":
|
|
577
|
+
return commandInstallSkill(args);
|
|
528
578
|
case "mcp-stdio":
|
|
529
579
|
case "serve-mcp": {
|
|
530
580
|
const { runLocalMcp } = await import("./local-mcp.ts");
|
package/src/index.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { Hono } from "hono";
|
|
|
2
2
|
import type { Env } from "./types.ts";
|
|
3
3
|
import { searchShowtimes, findFilmsByQuery, getComingSoon } from "./yorck/films.ts";
|
|
4
4
|
import { getCinemas, getCinemaBySlug } from "./yorck/cinemas.ts";
|
|
5
|
-
import { getSeatPlan, renderSeatPlanSvg } from "./yorck/seats.ts";
|
|
5
|
+
import { getSeatPlan, renderSeatPlanHtml, renderSeatPlanSvg } from "./yorck/seats.ts";
|
|
6
6
|
import { reserveUnlimited, cancelOrder, getOrder } from "./yorck/booking.ts";
|
|
7
7
|
import { whoAmI } from "./yorck/auth.ts";
|
|
8
8
|
import { showtimeToIcs } from "./lib/ics.ts";
|
|
9
9
|
import { PublicYorckMcp, YorckMcp } from "./mcp.ts";
|
|
10
|
+
import { installScript, YORCK_MOVIE_AGENT_SKILL } from "./skill-content.ts";
|
|
11
|
+
import { landingPage } from "./landing.ts";
|
|
10
12
|
|
|
11
13
|
export { PublicYorckMcp, YorckMcp };
|
|
12
14
|
|
|
@@ -34,92 +36,11 @@ function requireAuth(c: { req: { raw: Request }; env: Env; text: (body: string,
|
|
|
34
36
|
return c.text("unauthorized", 401, { "WWW-Authenticate": "Bearer" });
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
app.get("/", (c) =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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>`)
|
|
122
|
-
);
|
|
39
|
+
app.get("/", (c) => c.html(landingPage()));
|
|
40
|
+
|
|
41
|
+
app.get("/skill/SKILL.md", (c) => c.text(YORCK_MOVIE_AGENT_SKILL, 200, { "Content-Type": "text/markdown; charset=utf-8" }));
|
|
42
|
+
|
|
43
|
+
app.get("/install.sh", (c) => c.text(installScript("https://yorck-mcp.isiklimahir.workers.dev"), 200, { "Content-Type": "text/x-shellscript; charset=utf-8" }));
|
|
123
44
|
|
|
124
45
|
// REST endpoints (also handy for quick curl tests)
|
|
125
46
|
app.get("/v1/films", async (c) => {
|
|
@@ -189,6 +110,21 @@ app.get("/v1/seat-map/:sessionId", async (c) => {
|
|
|
189
110
|
return new Response(svg, { headers: { "Content-Type": "image/svg+xml" } });
|
|
190
111
|
});
|
|
191
112
|
|
|
113
|
+
app.get("/v1/seat-map-html/:sessionId", async (c) => {
|
|
114
|
+
const sessionId = c.req.param("sessionId");
|
|
115
|
+
const [cinemaVistaId, sessionNum] = sessionId.split("-");
|
|
116
|
+
if (!cinemaVistaId || !sessionNum) return c.text("bad sessionId", 400);
|
|
117
|
+
const cinemas = await getCinemas(c.env);
|
|
118
|
+
const cinema = cinemas.find((x) => x.vistaId === cinemaVistaId);
|
|
119
|
+
if (!cinema) return c.text("unknown cinema", 404);
|
|
120
|
+
const plan = await getSeatPlan(c.env, cinemaVistaId, sessionNum);
|
|
121
|
+
const html = renderSeatPlanHtml(plan, {
|
|
122
|
+
title: cinema.name,
|
|
123
|
+
subtitle: `Session ${sessionId}`,
|
|
124
|
+
});
|
|
125
|
+
return c.html(html);
|
|
126
|
+
});
|
|
127
|
+
|
|
192
128
|
// Public downloadable calendar file. This makes the read-only MCP useful even
|
|
193
129
|
// for clients that cannot write files themselves: the agent can hand the user a
|
|
194
130
|
// normal .ics URL that Apple Calendar, Google Calendar, and Outlook can import.
|
package/src/landing.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export function landingPage(): string {
|
|
2
|
+
return `<!doctype html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<title>yorck-mcp</title>
|
|
8
|
+
<meta name="description" content="Installable Yorck Berlin cinema MCP, CLI, and agent skill." />
|
|
9
|
+
<style>
|
|
10
|
+
:root{color-scheme:light dark;--bg:#f7f3eb;--fg:#171717;--muted:#6b625a;--card:rgba(255,255,255,.78);--line:rgba(45,37,30,.12);--accent:#d62828;--green:#166534;--blue:#355cff;--shadow:0 28px 90px rgba(44,32,23,.12)}
|
|
11
|
+
@media(prefers-color-scheme:dark){:root{--bg:#111;--fg:#f8f3eb;--muted:#b9afa5;--card:rgba(28,28,28,.78);--line:rgba(255,255,255,.12);--shadow:0 28px 90px rgba(0,0,0,.35)}}
|
|
12
|
+
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 12% 0%,rgba(214,40,40,.18),transparent 34rem),radial-gradient(circle at 80% 15%,rgba(53,92,255,.14),transparent 30rem),var(--bg);color:var(--fg);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1180px;margin:0 auto;padding:58px 22px 76px}.nav{display:flex;gap:10px;flex-wrap:wrap;align-items:center;justify-content:space-between;margin-bottom:58px}.brand{font-weight:900;letter-spacing:-.04em;font-size:24px}.links{display:flex;gap:10px;flex-wrap:wrap}.pill,.tab{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line);background:var(--card);backdrop-filter:blur(18px);border-radius:999px;padding:9px 13px;color:var(--muted);font-weight:800;font-size:14px;text-decoration:none}.hero{display:grid;grid-template-columns:minmax(0,1.08fr) minmax(320px,.92fr);gap:26px;align-items:center}.eyebrow{color:var(--accent);font-weight:900;letter-spacing:.14em;text-transform:uppercase;font-size:13px}h1{font-size:clamp(48px,8vw,104px);letter-spacing:-.085em;line-height:.88;margin:12px 0 18px}h2{font-size:30px;letter-spacing:-.045em;margin:0 0 12px}h3{font-size:20px;letter-spacing:-.025em;margin:0 0 8px}p{color:var(--muted);font-size:18px;line-height:1.58}.hero p{font-size:21px;max-width:720px}.cta{display:flex;gap:12px;flex-wrap:wrap;margin-top:28px}.button{border:1px solid var(--line);border-radius:999px;padding:13px 18px;text-decoration:none;font-weight:900;color:var(--fg);background:var(--card);box-shadow:var(--shadow)}.button.primary{background:#111;color:#fff}.card{background:var(--card);border:1px solid var(--line);border-radius:30px;padding:24px;box-shadow:var(--shadow);backdrop-filter:blur(20px)}.demo{display:grid;gap:14px}.screen{background:#111;color:#f8f3eb;border-radius:24px;padding:18px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;line-height:1.55;overflow:auto}.mini{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.metric{background:rgba(255,255,255,.62);border:1px solid var(--line);border-radius:20px;padding:15px;text-align:center}.metric b{display:block;font-size:30px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px;margin:22px 0}.section{margin-top:54px}.install-grid{display:grid;grid-template-columns:280px minmax(0,1fr);gap:18px}.tabs{display:grid;gap:8px}.tab{cursor:pointer;justify-content:flex-start}.tab.active{color:#fff;background:#111}.panel{display:none}.panel.active{display:block}.copy{float:right;border:0;border-radius:999px;background:#fff;color:#111;padding:6px 10px;font-weight:900;cursor:pointer}.list{padding-left:20px;color:var(--muted);line-height:1.8}.toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);background:#111;color:#fff;border-radius:999px;padding:10px 14px;font-weight:800;opacity:0;transition:.2s}.toast.show{opacity:1}@media(max-width:820px){.hero,.install-grid{grid-template-columns:1fr}h1{font-size:58px}.mini{grid-template-columns:1fr}}
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<main>
|
|
17
|
+
<nav class="nav"><div class="brand">yorck-mcp</div><div class="links"><a class="pill" href="/public/mcp">public MCP</a><a class="pill" href="/skill/SKILL.md">SKILL.md</a><a class="pill" href="/install.sh">install.sh</a><a class="pill" href="https://www.npmjs.com/package/yorck-mcp">npm</a></div></nav>
|
|
18
|
+
<section class="hero">
|
|
19
|
+
<div>
|
|
20
|
+
<div class="eyebrow">Berlin cinema for agents</div>
|
|
21
|
+
<h1>movie nights, as an installable agent skill.</h1>
|
|
22
|
+
<p>Search Yorck showtimes, render seat maps, create calendar files, return checkout links, and optionally book Unlimited tickets from a local MCP server with your own credentials.</p>
|
|
23
|
+
<div class="cta"><a class="button primary" href="#install">Install</a><a class="button" href="/skill/SKILL.md">Copy skill</a><a class="button" href="https://mahir.is/posts/yorck-mcp-public-cinema-agent">Read the build note</a></div>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="card demo">
|
|
26
|
+
<div class="screen">npx -y yorck-mcp plan --q "prada" --when tonight\n\n=> checkout link\n=> seat-plan summary\n=> calendar command</div>
|
|
27
|
+
<div class="mini"><div class="metric"><span>search</span><b>90ms</b></div><div class="metric"><span>mode</span><b>public</b></div><div class="metric"><span>booking</span><b>local</b></div></div>
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
30
|
+
|
|
31
|
+
<section class="section grid">
|
|
32
|
+
<div class="card"><h3>Public connector</h3><p>No account needed. Add a custom connector in Claude Web or Claude Desktop using the remote MCP endpoint.</p></div>
|
|
33
|
+
<div class="card"><h3>Local MCP</h3><p>Use Claude Code, Cursor, or local agents with <code>npx -y yorck-mcp mcp-stdio</code>.</p></div>
|
|
34
|
+
<div class="card"><h3>Agent skill</h3><p>Install one <code>SKILL.md</code> so Claude knows when to use SVG, inline HTML, checkout links, or private booking.</p></div>
|
|
35
|
+
<div class="card"><h3>HTML fallback</h3><p>If a client cannot render MCP images/SVG, use <code>seat_map_html</code> or <code>seat-map-html</code> for a portable inline page.</p></div>
|
|
36
|
+
</section>
|
|
37
|
+
|
|
38
|
+
<section class="section card" id="install">
|
|
39
|
+
<h2>Install options</h2>
|
|
40
|
+
<p>Pick the surface you are using. Public modes are read-only. Private booking uses local environment variables and confirmation gates.</p>
|
|
41
|
+
<div class="install-grid">
|
|
42
|
+
<div class="tabs">
|
|
43
|
+
<button class="tab active" data-tab="claude-web">Claude Web connector</button>
|
|
44
|
+
<button class="tab" data-tab="claude-code-public">Claude Code public</button>
|
|
45
|
+
<button class="tab" data-tab="claude-code-private">Claude Code private</button>
|
|
46
|
+
<button class="tab" data-tab="skill">Claude skill</button>
|
|
47
|
+
<button class="tab" data-tab="cli">CLI</button>
|
|
48
|
+
<button class="tab" data-tab="html">Inline HTML fallback</button>
|
|
49
|
+
</div>
|
|
50
|
+
<div>
|
|
51
|
+
<div class="panel active" id="claude-web"><h3>Claude Web / custom connector</h3><p>Customize → Connectors → Add custom connector, then paste:</p><pre class="screen"><button class="copy">copy</button>https://yorck-mcp.isiklimahir.workers.dev/public/mcp</pre></div>
|
|
52
|
+
<div class="panel" id="claude-code-public"><h3>Claude Code, public tools</h3><pre class="screen"><button class="copy">copy</button>claude mcp add --transport http yorck https://yorck-mcp.isiklimahir.workers.dev/public/mcp</pre></div>
|
|
53
|
+
<div class="panel" id="claude-code-private"><h3>Claude Code, local bookable MCP</h3><pre class="screen"><button class="copy">copy</button>claude mcp add --transport stdio \\
|
|
54
|
+
--env YORCK_EMAIL=you@example.com \\
|
|
55
|
+
--env YORCK_PASSWORD=your-password \\
|
|
56
|
+
--env YORCK_UNLIMITED_CARD=your-card-number \\
|
|
57
|
+
yorck -- npx -y yorck-mcp mcp-stdio</pre></div>
|
|
58
|
+
<div class="panel" id="skill"><h3>Install the skill file</h3><pre class="screen"><button class="copy">copy</button>curl -fsSL https://yorck-mcp.isiklimahir.workers.dev/install.sh | bash</pre><p>Or from npm:</p><pre class="screen"><button class="copy">copy</button>npx -y yorck-mcp install-skill --target claude</pre></div>
|
|
59
|
+
<div class="panel" id="cli"><h3>Use it directly</h3><pre class="screen"><button class="copy">copy</button>npx -y yorck-mcp whats-on --when tonight --after 18:00
|
|
60
|
+
npx -y yorck-mcp plan --q "devil wears prada" --when tonight
|
|
61
|
+
npx -y yorck-mcp seat-map 1007-30456 --out seat-map.svg</pre></div>
|
|
62
|
+
<div class="panel" id="html"><h3>When SVG does not render</h3><pre class="screen"><button class="copy">copy</button>npx -y yorck-mcp seat-map-html 1007-30456 --out seat-map.html
|
|
63
|
+
open seat-map.html</pre></div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
|
|
68
|
+
<section class="section grid">
|
|
69
|
+
<div class="card"><h2>Public tools</h2><ul class="list"><li><code>whats_on</code>, search showtimes</li><li><code>pick_showtime</code>, pick one good plan</li><li><code>seat_map</code>, SVG/image output</li><li><code>seat_map_html</code>, inline HTML fallback</li><li><code>add_to_calendar</code>, ICS file</li></ul></div>
|
|
70
|
+
<div class="card"><h2>Private tools</h2><ul class="list"><li><code>book_session</code>, Unlimited booking after confirmation</li><li><code>cancel_booking</code>, release a held order</li><li>Paid checkout stays on Yorck's website via checkout link.</li></ul></div>
|
|
71
|
+
</section>
|
|
72
|
+
</main>
|
|
73
|
+
<div class="toast" id="toast">copied</div>
|
|
74
|
+
<script>
|
|
75
|
+
const tabs=[...document.querySelectorAll('.tab')],panels=[...document.querySelectorAll('.panel')];
|
|
76
|
+
tabs.forEach(t=>t.onclick=()=>{tabs.forEach(x=>x.classList.remove('active'));panels.forEach(p=>p.classList.remove('active'));t.classList.add('active');document.getElementById(t.dataset.tab).classList.add('active')});
|
|
77
|
+
const toast=document.getElementById('toast');
|
|
78
|
+
document.querySelectorAll('.copy').forEach(btn=>btn.onclick=async()=>{const pre=btn.parentElement;const txt=pre.innerText.replace(/^copy\n?/,'');await navigator.clipboard.writeText(txt);toast.classList.add('show');setTimeout(()=>toast.classList.remove('show'),1200)});
|
|
79
|
+
</script>
|
|
80
|
+
</body>
|
|
81
|
+
</html>`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export const YORCK_MOVIE_AGENT_SKILL = `---
|
|
2
|
+
name: yorck-movie-agent
|
|
3
|
+
description: >-
|
|
4
|
+
Plan movie nights with Yorck Berlin cinema. Use when the user asks for movie showtimes, seat maps, checkout links, calendar files, or booking through the yorck-mcp connector/CLI. Handles Claude Web remote MCP, Claude Code local MCP, inline HTML fallbacks, and confirmation-gated booking.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Yorck Movie Agent
|
|
8
|
+
|
|
9
|
+
Use this skill when a user asks for Berlin Yorck movie planning, seat maps, direct checkout links, calendar files, or booking via the Yorck MCP/CLI.
|
|
10
|
+
|
|
11
|
+
## Available install modes
|
|
12
|
+
|
|
13
|
+
### Claude Web / Claude connectors
|
|
14
|
+
|
|
15
|
+
Remote read-only MCP endpoint:
|
|
16
|
+
|
|
17
|
+
\`\`\`txt
|
|
18
|
+
https://yorck-mcp.isiklimahir.workers.dev/public/mcp
|
|
19
|
+
\`\`\`
|
|
20
|
+
|
|
21
|
+
Use this when Claude Web, Claude Desktop connectors, Cowork, or mobile can add a custom connector. It is public and read-only: showtimes, films, cinemas, seat maps, calendar files, and checkout links. It does not book.
|
|
22
|
+
|
|
23
|
+
### Claude Code / local agents
|
|
24
|
+
|
|
25
|
+
Public/local MCP:
|
|
26
|
+
|
|
27
|
+
\`\`\`bash
|
|
28
|
+
claude mcp add --transport stdio yorck -- npx -y yorck-mcp mcp-stdio
|
|
29
|
+
\`\`\`
|
|
30
|
+
|
|
31
|
+
Private local MCP with booking credentials:
|
|
32
|
+
|
|
33
|
+
\`\`\`bash
|
|
34
|
+
claude mcp add --transport stdio \\
|
|
35
|
+
--env YORCK_EMAIL=you@example.com \\
|
|
36
|
+
--env YORCK_PASSWORD=your-password \\
|
|
37
|
+
--env YORCK_UNLIMITED_CARD=your-card-number \\
|
|
38
|
+
yorck -- npx -y yorck-mcp mcp-stdio
|
|
39
|
+
\`\`\`
|
|
40
|
+
|
|
41
|
+
### Terminal CLI
|
|
42
|
+
|
|
43
|
+
\`\`\`bash
|
|
44
|
+
npx -y yorck-mcp whats-on --when tonight --after 18:00
|
|
45
|
+
npx -y yorck-mcp plan --q "devil wears prada" --when tonight --after 18:00
|
|
46
|
+
npx -y yorck-mcp seat-map <session-id> --out seat-map.svg
|
|
47
|
+
npx -y yorck-mcp calendar <session-id> <film-slug> --out movie.ics
|
|
48
|
+
\`\`\`
|
|
49
|
+
|
|
50
|
+
## Tool use policy
|
|
51
|
+
|
|
52
|
+
Prefer tools in this order:
|
|
53
|
+
|
|
54
|
+
1. \`pick_showtime\`, when the user wants one good movie plan or a direct checkout link.
|
|
55
|
+
2. \`whats_on\`, when the user wants multiple options.
|
|
56
|
+
3. \`find_film\` then \`showtimes\`, when the user has a specific film title.
|
|
57
|
+
4. \`seat_map\`, when the environment can render or display SVG/image content.
|
|
58
|
+
5. \`seat_map_html\`, when SVG/image content will not display, such as some Claude Code text-only surfaces.
|
|
59
|
+
6. \`add_to_calendar\`, only when the user wants an ICS/calendar file.
|
|
60
|
+
7. \`book_session\`, only in private/local mode after explicit confirmation.
|
|
61
|
+
|
|
62
|
+
## Seat map output by environment
|
|
63
|
+
|
|
64
|
+
### If images/SVG render correctly
|
|
65
|
+
|
|
66
|
+
Use \`seat_map\`. It returns structured rows plus an SVG image.
|
|
67
|
+
|
|
68
|
+
### If SVG does not render
|
|
69
|
+
|
|
70
|
+
Use \`seat_map_html\` and show or save the inline HTML. If the client cannot open inline HTML directly, write the HTML to a local file and tell the user to open it.
|
|
71
|
+
|
|
72
|
+
In Claude Code, prefer:
|
|
73
|
+
|
|
74
|
+
\`\`\`bash
|
|
75
|
+
npx -y yorck-mcp seat-map-html <session-id> --out seat-map.html
|
|
76
|
+
open seat-map.html
|
|
77
|
+
\`\`\`
|
|
78
|
+
|
|
79
|
+
### If neither SVG nor HTML is possible
|
|
80
|
+
|
|
81
|
+
Use the structured rows from \`seat_map\` or \`pick_showtime\` and summarize:
|
|
82
|
+
|
|
83
|
+
- film
|
|
84
|
+
- cinema
|
|
85
|
+
- time
|
|
86
|
+
- format
|
|
87
|
+
- available row labels and available seat IDs
|
|
88
|
+
- direct checkout URL
|
|
89
|
+
|
|
90
|
+
Do not pretend API seat IDs are exactly the same as the visible Yorck website labels. If the user books manually, have them choose seats on Yorck's page.
|
|
91
|
+
|
|
92
|
+
## Public vs private behavior
|
|
93
|
+
|
|
94
|
+
Public mode needs no account. It can:
|
|
95
|
+
|
|
96
|
+
- search showtimes
|
|
97
|
+
- list cinemas
|
|
98
|
+
- render seat maps
|
|
99
|
+
- create ICS calendar files
|
|
100
|
+
- return direct checkout links
|
|
101
|
+
|
|
102
|
+
Private/local mode can book with credentials. It currently supports Yorck Unlimited booking. Paid checkout should be completed on Yorck's own page using the direct checkout link.
|
|
103
|
+
|
|
104
|
+
## Safety rules
|
|
105
|
+
|
|
106
|
+
- Never claim a booking was made unless \`book_session\` returned success.
|
|
107
|
+
- Never claim a calendar event was added unless a calendar tool actually succeeded.
|
|
108
|
+
- Ask for explicit confirmation before booking.
|
|
109
|
+
- Treat \`dryRun: true\` as the default for booking tests.
|
|
110
|
+
- If no Unlimited card is configured, use \`pick_showtime\` and provide the checkout link.
|
|
111
|
+
|
|
112
|
+
## Example prompts this skill should handle
|
|
113
|
+
|
|
114
|
+
- "what original-language movies are playing tonight after 7?"
|
|
115
|
+
- "show me a seat map for this Yorck session"
|
|
116
|
+
- "give me an inline HTML seat map because SVG is not rendering"
|
|
117
|
+
- "plan a low-stress movie night for me"
|
|
118
|
+
- "book the best option if my Unlimited card makes it free"
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
export function installScript(baseUrl = "https://yorck-mcp.isiklimahir.workers.dev") {
|
|
122
|
+
return `#!/usr/bin/env bash
|
|
123
|
+
set -euo pipefail
|
|
124
|
+
BASE_URL="${baseUrl}"
|
|
125
|
+
DEST="\${DEST:-$HOME/.claude/skills/yorck-movie-agent}"
|
|
126
|
+
mkdir -p "$DEST"
|
|
127
|
+
curl -fsSL "$BASE_URL/skill/SKILL.md" -o "$DEST/SKILL.md"
|
|
128
|
+
printf 'installed yorck movie agent skill to %s\n' "$DEST"
|
|
129
|
+
printf 'next: add the MCP connector from %s or run: claude mcp add --transport stdio yorck -- npx -y yorck-mcp mcp-stdio\n' "$BASE_URL"
|
|
130
|
+
`;
|
|
131
|
+
}
|
package/src/tool-registration.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import type { Env, Showtime } from "./types.ts";
|
|
4
4
|
import { searchShowtimes, findFilmsByQuery, getComingSoon } from "./yorck/films.ts";
|
|
5
5
|
import { getCinemas } from "./yorck/cinemas.ts";
|
|
6
|
-
import { getSeatPlan, renderSeatPlanSvg } from "./yorck/seats.ts";
|
|
6
|
+
import { getSeatPlan, renderSeatPlanHtml, renderSeatPlanSvg } from "./yorck/seats.ts";
|
|
7
7
|
import { reserveUnlimited, cancelOrder, commitOrder } from "./yorck/booking.ts";
|
|
8
8
|
import { showtimeToIcs } from "./lib/ics.ts";
|
|
9
9
|
import { nowBerlinIso } from "./lib/tz.ts";
|
|
@@ -195,6 +195,33 @@ export function registerPublicTools(server: McpServer, env: Env) {
|
|
|
195
195
|
}
|
|
196
196
|
);
|
|
197
197
|
|
|
198
|
+
server.tool(
|
|
199
|
+
"seat_map_html",
|
|
200
|
+
"Returns a complete inline HTML seat-map page for a session. Use this when the MCP client cannot display SVG/image output directly, such as text-only Claude Code surfaces.",
|
|
201
|
+
{
|
|
202
|
+
sessionId: z.string().regex(/^\d{4}-\d+$/).describe("Yorck session id, e.g. '1003-5724'"),
|
|
203
|
+
},
|
|
204
|
+
async ({ sessionId }) => {
|
|
205
|
+
const [cinemaVistaId, sessionNum] = sessionId.split("-");
|
|
206
|
+
const cinema = await (async () => {
|
|
207
|
+
const all = await getCinemas(env);
|
|
208
|
+
return all.find((c) => c.vistaId === cinemaVistaId);
|
|
209
|
+
})();
|
|
210
|
+
if (!cinema) throw new Error(`unknown cinema for session ${sessionId}`);
|
|
211
|
+
const plan = await getSeatPlan(env, cinemaVistaId, sessionNum);
|
|
212
|
+
const html = renderSeatPlanHtml(plan, {
|
|
213
|
+
title: `${cinema.name}`,
|
|
214
|
+
subtitle: `Session ${sessionId}`,
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
content: [{
|
|
218
|
+
type: "text" as const,
|
|
219
|
+
text: JSON.stringify({ sessionId, cinema: cinema.name, html }, null, 2),
|
|
220
|
+
}],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
|
|
198
225
|
server.tool(
|
|
199
226
|
"pick_showtime",
|
|
200
227
|
"Read-only helper that searches showtimes, chooses a reasonable available seat, and returns a manual booking URL. No account, reservation, or payment needed.",
|
|
@@ -210,10 +237,11 @@ export function registerPublicTools(server: McpServer, env: Env) {
|
|
|
210
237
|
content: [{
|
|
211
238
|
type: "text" as const,
|
|
212
239
|
text: JSON.stringify({
|
|
213
|
-
selected:
|
|
240
|
+
selected: showtime,
|
|
214
241
|
manualBookingUrl: sessionBookingUrl(env, showtime),
|
|
215
242
|
filmPageUrl: showtime.url,
|
|
216
|
-
|
|
243
|
+
apiSeatForAutomation: { row: seat.rowLabel, seatId: seat.seatId },
|
|
244
|
+
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.",
|
|
217
245
|
rows,
|
|
218
246
|
}, null, 2),
|
|
219
247
|
}],
|
package/src/yorck/seats.ts
CHANGED
|
@@ -62,7 +62,7 @@ export interface SvgRenderOpts {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function renderSeatPlanSvg(plan: SeatPlanResponse, opts: SvgRenderOpts = {}): string {
|
|
65
|
-
const area = plan.SeatLayoutData
|
|
65
|
+
const area = plan.SeatLayoutData?.Areas?.[0];
|
|
66
66
|
if (!area) return `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40"><text>No seat data</text></svg>`;
|
|
67
67
|
|
|
68
68
|
const COL_COUNT = area.ColumnCount;
|
|
@@ -149,6 +149,40 @@ export function renderSeatPlanSvg(plan: SeatPlanResponse, opts: SvgRenderOpts =
|
|
|
149
149
|
return out.join("\n");
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
export function renderSeatPlanHtml(plan: SeatPlanResponse, opts: SvgRenderOpts = {}): string {
|
|
153
|
+
const area = plan.SeatLayoutData?.Areas?.[0];
|
|
154
|
+
const svg = renderSeatPlanSvg(plan, opts);
|
|
155
|
+
const rows = area?.Rows ?? [];
|
|
156
|
+
const seats = rows.flatMap((row) => row.Seats ?? []);
|
|
157
|
+
const total = seats.length;
|
|
158
|
+
const available = seats.filter((seat) => seat.Status === 0).length;
|
|
159
|
+
const taken = seats.filter((seat) => seat.Status !== 0).length;
|
|
160
|
+
const title = opts.title || "Yorck seat map";
|
|
161
|
+
const subtitle = opts.subtitle || "";
|
|
162
|
+
return `<!doctype html>
|
|
163
|
+
<html lang="en">
|
|
164
|
+
<head>
|
|
165
|
+
<meta charset="utf-8" />
|
|
166
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
167
|
+
<title>${escapeXml(title)}</title>
|
|
168
|
+
<style>
|
|
169
|
+
body{margin:0;background:#f7f6f2;color:#181818;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:42px 20px}
|
|
170
|
+
main{max-width:1160px;margin:0 auto}.eyebrow{text-transform:uppercase;letter-spacing:.12em;color:#777;font-weight:800;font-size:13px}h1{font-size:clamp(34px,5vw,56px);letter-spacing:-.05em;margin:8px 0 8px}.sub{color:#666;margin:0 0 28px;font-size:18px}.stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:22px 0}.stat{background:white;border:1px solid #e3dfd6;border-radius:22px;padding:18px;text-align:center}.stat b{display:block;font-size:38px;letter-spacing:-.04em}.map{background:#101010;border-radius:28px;overflow:auto;box-shadow:0 20px 70px #0002}.map svg{width:100%;height:auto;display:block}.hint{margin-top:18px;color:#666;line-height:1.5}@media(max-width:720px){.stats{grid-template-columns:1fr}}
|
|
171
|
+
</style>
|
|
172
|
+
</head>
|
|
173
|
+
<body>
|
|
174
|
+
<main>
|
|
175
|
+
<div class="eyebrow">inline HTML fallback</div>
|
|
176
|
+
<h1>${escapeXml(title)}</h1>
|
|
177
|
+
${subtitle ? `<p class="sub">${escapeXml(subtitle)}</p>` : ""}
|
|
178
|
+
<section class="stats"><div class="stat"><span>Total seats</span><b>${total}</b></div><div class="stat"><span>Available</span><b style="color:#166534">${available}</b></div><div class="stat"><span>Unavailable</span><b style="color:#7f1d1d">${taken}</b></div></section>
|
|
179
|
+
<section class="map">${svg}</section>
|
|
180
|
+
<p class="hint">Green seats are available. This view is a portable HTML fallback for clients that cannot display MCP image/SVG output directly.</p>
|
|
181
|
+
</main>
|
|
182
|
+
</body>
|
|
183
|
+
</html>`;
|
|
184
|
+
}
|
|
185
|
+
|
|
152
186
|
function escapeXml(s: string): string {
|
|
153
187
|
return s
|
|
154
188
|
.replace(/&/g, "&")
|