yorck-mcp 0.1.2 → 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 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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yorck-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "scripts": {
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");
@@ -426,6 +440,32 @@ async function commandMe(env: Env, args: ParsedArgs) {
426
440
  printJson(await whoAmI(env));
427
441
  }
428
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
+
429
469
  function commandMcpConfig(args: ParsedArgs) {
430
470
  const privateUrl = str(args, "url") || process.env.YORCK_MCP_URL || "https://yorck-mcp.isiklimahir.workers.dev/mcp";
431
471
  if (bool(args, "remotePrivate")) {
@@ -476,6 +516,7 @@ Usage:
476
516
  yorck cinemas
477
517
  yorck coming-soon
478
518
  yorck seat-map <session-id> [--out seat-map.svg]
519
+ yorck seat-map-html <session-id> [--out seat-map.html]
479
520
  yorck calendar <session-id> <film-slug> [--out event.ics]
480
521
  yorck plan --q <film> --when tonight --after 18:00
481
522
  yorck me [--email you@example.com --password ...]
@@ -483,6 +524,8 @@ Usage:
483
524
  yorck book-best --q <film> --when tonight --after 18:00 [--commit --yes]
484
525
  yorck mcp-stdio
485
526
  yorck mcp-config [--private]
527
+ yorck skill [--out SKILL.md]
528
+ yorck install-skill [--target claude|pi|both]
486
529
 
487
530
  Public commands need no account. Booking commands need Yorck credentials and an Unlimited card number.
488
531
  Use env vars instead of flags for secrets: YORCK_EMAIL, YORCK_PASSWORD, YORCK_UNLIMITED_CARD.
@@ -512,6 +555,8 @@ async function main() {
512
555
  return printJson(await getComingSoon(env));
513
556
  case "seat-map":
514
557
  return commandSeatMap(env, args);
558
+ case "seat-map-html":
559
+ return commandSeatMapHtml(env, args);
515
560
  case "calendar":
516
561
  case "ics":
517
562
  return commandCalendar(env, args);
@@ -526,6 +571,10 @@ async function main() {
526
571
  return commandBookBest(env, args);
527
572
  case "mcp-config":
528
573
  return commandMcpConfig(args);
574
+ case "skill":
575
+ return commandSkill(args);
576
+ case "install-skill":
577
+ return commandInstallSkill(args);
529
578
  case "mcp-stdio":
530
579
  case "serve-mcp": {
531
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
- 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>`)
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
+ }
@@ -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.",
@@ -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.Areas[0];
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, "&amp;")