yorck-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/srp.ts ADDED
@@ -0,0 +1,248 @@
1
+ // AWS Cognito SRP (Secure Remote Password) auth, implemented for Cloudflare Workers
2
+ // using only standards-track Web APIs (BigInt, crypto.subtle, TextEncoder).
3
+ //
4
+ // Reference: https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow.html
5
+ // AWS uses SRP-6a with N = 3072-bit prime, g = 2, hash = SHA-256, plus a custom HKDF.
6
+
7
+ // 3072-bit prime N from RFC 5054 group 14? AWS uses RFC 5054 group N (a specific 3072-bit prime).
8
+ // This is the well-known prime used by AWS Cognito.
9
+ const N_HEX =
10
+ "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E08" +
11
+ "8A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B" +
12
+ "302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9" +
13
+ "A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE6" +
14
+ "49286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8" +
15
+ "FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D" +
16
+ "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C" +
17
+ "180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718" +
18
+ "3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D" +
19
+ "04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7D" +
20
+ "B3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D226" +
21
+ "1AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200C" +
22
+ "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFC" +
23
+ "E0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF";
24
+
25
+ const G_HEX = "2";
26
+
27
+ const N = BigInt("0x" + N_HEX);
28
+ const G = BigInt("0x" + G_HEX);
29
+
30
+ const enc = new TextEncoder();
31
+
32
+ function hexToBytes(hex: string): Uint8Array {
33
+ if (hex.length % 2) hex = "0" + hex;
34
+ const out = new Uint8Array(hex.length / 2);
35
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
36
+ return out;
37
+ }
38
+
39
+ function bytesToHex(bytes: Uint8Array): string {
40
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
41
+ }
42
+
43
+ function bigIntToHex(n: bigint): string {
44
+ let h = n.toString(16);
45
+ if (h.length % 2) h = "0" + h;
46
+ return h;
47
+ }
48
+
49
+ // Pad hex with a leading 00 if its top bit is set, per Cognito's serialization quirks.
50
+ function padHex(h: string): string {
51
+ if (h.length % 2) h = "0" + h;
52
+ if (parseInt(h[0], 16) >= 8) h = "00" + h;
53
+ return h;
54
+ }
55
+
56
+ async function sha256(data: Uint8Array): Promise<Uint8Array> {
57
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", data as BufferSource));
58
+ }
59
+
60
+ async function hexHash(hex: string): Promise<string> {
61
+ return bytesToHex(await sha256(hexToBytes(hex)));
62
+ }
63
+
64
+ function concatBytes(...arrs: Uint8Array[]): Uint8Array {
65
+ const total = arrs.reduce((n, a) => n + a.length, 0);
66
+ const out = new Uint8Array(total);
67
+ let off = 0;
68
+ for (const a of arrs) {
69
+ out.set(a, off);
70
+ off += a.length;
71
+ }
72
+ return out;
73
+ }
74
+
75
+ async function hmacSha256(key: Uint8Array, msg: Uint8Array): Promise<Uint8Array> {
76
+ const k = await crypto.subtle.importKey("raw", key as BufferSource, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
77
+ return new Uint8Array(await crypto.subtle.sign("HMAC", k, msg as BufferSource));
78
+ }
79
+
80
+ // AWS-specific HKDF using SHA-256, no info salt — exact same as Cognito's PasswordAuthenticationKey impl.
81
+ async function awsHkdf(ikm: Uint8Array, salt: Uint8Array): Promise<Uint8Array> {
82
+ const prk = await hmacSha256(salt, ikm);
83
+ const info = enc.encode("Caldera Derived Key");
84
+ const t1 = await hmacSha256(prk, concatBytes(info, new Uint8Array([1])));
85
+ return t1.slice(0, 16);
86
+ }
87
+
88
+ // Constant-time-ish modular exponentiation for BigInt (left-to-right binary).
89
+ function modPow(base: bigint, exp: bigint, mod: bigint): bigint {
90
+ let result = 1n;
91
+ base = base % mod;
92
+ let e = exp;
93
+ while (e > 0n) {
94
+ if (e & 1n) result = (result * base) % mod;
95
+ e >>= 1n;
96
+ base = (base * base) % mod;
97
+ }
98
+ return result;
99
+ }
100
+
101
+ function randomBigInt(byteLen: number): bigint {
102
+ const buf = new Uint8Array(byteLen);
103
+ crypto.getRandomValues(buf);
104
+ return BigInt("0x" + bytesToHex(buf));
105
+ }
106
+
107
+ export interface CognitoTokens {
108
+ IdToken: string;
109
+ AccessToken: string;
110
+ RefreshToken: string;
111
+ ExpiresIn: number;
112
+ TokenType: string;
113
+ }
114
+
115
+ const KCal = (async () => {
116
+ // k = H(N || g)
117
+ return BigInt("0x" + (await hexHash(padHex(N_HEX) + padHex(G_HEX))));
118
+ })();
119
+
120
+ export async function cognitoSrpLogin(args: {
121
+ region: string;
122
+ userPoolId: string;
123
+ clientId: string;
124
+ username: string;
125
+ password: string;
126
+ }): Promise<CognitoTokens> {
127
+ const { region, userPoolId, clientId, username, password } = args;
128
+ const cognitoUrl = `https://cognito-idp.${region}.amazonaws.com/`;
129
+ const userPoolName = userPoolId.split("_")[1];
130
+
131
+ // 1) Generate client SRP_a, SRP_A.
132
+ const a = randomBigInt(128);
133
+ const A = modPow(G, a, N);
134
+ if (A % N === 0n) throw new Error("SRP_A is zero");
135
+ const srpA = padHex(bigIntToHex(A));
136
+
137
+ // 2) InitiateAuth USER_SRP_AUTH
138
+ const initRes = await fetch(cognitoUrl, {
139
+ method: "POST",
140
+ headers: {
141
+ "Content-Type": "application/x-amz-json-1.1",
142
+ "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth",
143
+ },
144
+ body: JSON.stringify({
145
+ AuthFlow: "USER_SRP_AUTH",
146
+ ClientId: clientId,
147
+ AuthParameters: { USERNAME: username, SRP_A: srpA },
148
+ }),
149
+ });
150
+ if (!initRes.ok) throw new Error(`InitiateAuth ${initRes.status}: ${await initRes.text()}`);
151
+ const init = (await initRes.json()) as {
152
+ ChallengeName?: string;
153
+ ChallengeParameters: {
154
+ USER_ID_FOR_SRP: string;
155
+ SRP_B: string;
156
+ SALT: string;
157
+ SECRET_BLOCK: string;
158
+ };
159
+ };
160
+
161
+ if (init.ChallengeName !== "PASSWORD_VERIFIER") {
162
+ throw new Error(`Unexpected challenge: ${init.ChallengeName}`);
163
+ }
164
+ const userIdForSrp = init.ChallengeParameters.USER_ID_FOR_SRP;
165
+ const srpB = init.ChallengeParameters.SRP_B;
166
+ const salt = init.ChallengeParameters.SALT;
167
+ const secretBlock = init.ChallengeParameters.SECRET_BLOCK;
168
+ const B = BigInt("0x" + srpB);
169
+ if (B % N === 0n) throw new Error("SRP_B is zero");
170
+
171
+ // 3) u = H(A || B)
172
+ const u = BigInt("0x" + (await hexHash(padHex(bigIntToHex(A)) + padHex(srpB))));
173
+ if (u === 0n) throw new Error("u is zero");
174
+
175
+ // 4) x = H(salt || H(poolName || username || ':' || password))
176
+ const innerHashHex = bytesToHex(
177
+ await sha256(enc.encode(`${userPoolName}${userIdForSrp}:${password}`))
178
+ );
179
+ const xHex = await hexHash(padHex(salt) + innerHashHex);
180
+ const x = BigInt("0x" + xHex);
181
+
182
+ // 5) k = H(N || g) ; gx = g^x ; intermediate = (B - k * gx) mod N ; S = (intermediate)^(a + u*x) mod N
183
+ const k = await KCal;
184
+ const gx = modPow(G, x, N);
185
+ let intermediate = (B - ((k * gx) % N) + N * 2n) % N; // ensure positive
186
+ const S = modPow(intermediate, a + u * x, N);
187
+
188
+ // 6) HKDF derive password authentication key
189
+ const hkdf = await awsHkdf(hexToBytes(padHex(bigIntToHex(S))), hexToBytes(padHex(bigIntToHex(u))));
190
+
191
+ // 7) Build PASSWORD_CLAIM_SIGNATURE = HMAC(hkdf, poolName || userIdForSrp || secretBlock || timestamp)
192
+ const now = new Date();
193
+ // AWS expects: "EEE MMM d HH:mm:ss UTC yyyy" (English locale, single-digit day no zero pad)
194
+ const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
195
+ const months = [
196
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
197
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
198
+ ];
199
+ const pad2 = (n: number) => String(n).padStart(2, "0");
200
+ const timestamp =
201
+ `${days[now.getUTCDay()]} ${months[now.getUTCMonth()]} ${now.getUTCDate()} ` +
202
+ `${pad2(now.getUTCHours())}:${pad2(now.getUTCMinutes())}:${pad2(now.getUTCSeconds())} UTC ` +
203
+ `${now.getUTCFullYear()}`;
204
+
205
+ const secretBlockBytes = Uint8Array.from(atob(secretBlock), (c) => c.charCodeAt(0));
206
+ const claimMsg = concatBytes(
207
+ enc.encode(userPoolName),
208
+ enc.encode(userIdForSrp),
209
+ secretBlockBytes,
210
+ enc.encode(timestamp)
211
+ );
212
+ const sig = await hmacSha256(hkdf, claimMsg);
213
+ const sigB64 = btoa(String.fromCharCode(...sig));
214
+
215
+ // 8) RespondToAuthChallenge
216
+ const respRes = await fetch(cognitoUrl, {
217
+ method: "POST",
218
+ headers: {
219
+ "Content-Type": "application/x-amz-json-1.1",
220
+ "X-Amz-Target": "AWSCognitoIdentityProviderService.RespondToAuthChallenge",
221
+ },
222
+ body: JSON.stringify({
223
+ ClientId: clientId,
224
+ ChallengeName: "PASSWORD_VERIFIER",
225
+ ChallengeResponses: {
226
+ USERNAME: userIdForSrp,
227
+ PASSWORD_CLAIM_SECRET_BLOCK: secretBlock,
228
+ PASSWORD_CLAIM_SIGNATURE: sigB64,
229
+ TIMESTAMP: timestamp,
230
+ },
231
+ }),
232
+ });
233
+ if (!respRes.ok) throw new Error(`RespondToAuthChallenge ${respRes.status}: ${await respRes.text()}`);
234
+ const resp = (await respRes.json()) as {
235
+ AuthenticationResult?: {
236
+ IdToken: string;
237
+ AccessToken: string;
238
+ RefreshToken: string;
239
+ ExpiresIn: number;
240
+ TokenType: string;
241
+ };
242
+ ChallengeName?: string;
243
+ };
244
+ if (!resp.AuthenticationResult) {
245
+ throw new Error(`Auth failed: ${JSON.stringify(resp)}`);
246
+ }
247
+ return resp.AuthenticationResult;
248
+ }
package/src/lib/tz.ts ADDED
@@ -0,0 +1,75 @@
1
+ // Yorck stores session times with a fixed +01:00 offset year-round (CMS bug).
2
+ // The local part of the timestamp is the actual Berlin local time. Re-anchor.
3
+
4
+ const BERLIN_TZ = "Europe/Berlin";
5
+
6
+ function berlinOffsetMinutes(date: Date): number {
7
+ const fmt = new Intl.DateTimeFormat("en-US", {
8
+ timeZone: BERLIN_TZ,
9
+ timeZoneName: "longOffset",
10
+ });
11
+ const parts = fmt.formatToParts(date);
12
+ const tz = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+01:00";
13
+ const m = tz.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
14
+ if (!m) return 60;
15
+ const sign = m[1] === "+" ? 1 : -1;
16
+ const h = parseInt(m[2], 10);
17
+ const min = m[3] ? parseInt(m[3], 10) : 0;
18
+ return sign * (h * 60 + min);
19
+ }
20
+
21
+ // Convert Yorck-formatted "2026-05-08T17:30:00+01:00" to a proper Berlin-local ISO string.
22
+ // We treat the local clock face as Berlin local time and apply the correct offset.
23
+ export function fixYorckTime(yorckIso: string): string {
24
+ const m = yorckIso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
25
+ if (!m) return yorckIso;
26
+ const [, y, mo, d, h, mi, s] = m;
27
+ // Build a tentative Date in UTC representing the wall-clock time
28
+ const tentative = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s));
29
+ const offsetMin = berlinOffsetMinutes(tentative);
30
+ const sign = offsetMin >= 0 ? "+" : "-";
31
+ const abs = Math.abs(offsetMin);
32
+ const oh = String(Math.floor(abs / 60)).padStart(2, "0");
33
+ const om = String(abs % 60).padStart(2, "0");
34
+ return `${y}-${mo}-${d}T${h}:${mi}:${s}${sign}${oh}:${om}`;
35
+ }
36
+
37
+ export function addMinutesIso(berlinIso: string, minutes: number): string {
38
+ const d = new Date(berlinIso);
39
+ d.setUTCMinutes(d.getUTCMinutes() + minutes);
40
+ // Re-emit in Berlin local
41
+ const off = berlinOffsetMinutes(d);
42
+ const local = new Date(d.getTime() + off * 60_000);
43
+ const y = local.getUTCFullYear();
44
+ const mo = String(local.getUTCMonth() + 1).padStart(2, "0");
45
+ const da = String(local.getUTCDate()).padStart(2, "0");
46
+ const h = String(local.getUTCHours()).padStart(2, "0");
47
+ const mi = String(local.getUTCMinutes()).padStart(2, "0");
48
+ const s = String(local.getUTCSeconds()).padStart(2, "0");
49
+ const sign = off >= 0 ? "+" : "-";
50
+ const abs = Math.abs(off);
51
+ const oh = String(Math.floor(abs / 60)).padStart(2, "0");
52
+ const om = String(abs % 60).padStart(2, "0");
53
+ return `${y}-${mo}-${da}T${h}:${mi}:${s}${sign}${oh}:${om}`;
54
+ }
55
+
56
+ export function nowBerlinIso(): string {
57
+ const now = new Date();
58
+ const off = berlinOffsetMinutes(now);
59
+ const local = new Date(now.getTime() + off * 60_000);
60
+ const y = local.getUTCFullYear();
61
+ const mo = String(local.getUTCMonth() + 1).padStart(2, "0");
62
+ const da = String(local.getUTCDate()).padStart(2, "0");
63
+ const h = String(local.getUTCHours()).padStart(2, "0");
64
+ const mi = String(local.getUTCMinutes()).padStart(2, "0");
65
+ const s = String(local.getUTCSeconds()).padStart(2, "0");
66
+ const sign = off >= 0 ? "+" : "-";
67
+ const abs = Math.abs(off);
68
+ const oh = String(Math.floor(abs / 60)).padStart(2, "0");
69
+ const om = String(abs % 60).padStart(2, "0");
70
+ return `${y}-${mo}-${da}T${h}:${mi}:${s}${sign}${oh}:${om}`;
71
+ }
72
+
73
+ export function todayBerlinDate(): string {
74
+ return nowBerlinIso().slice(0, 10);
75
+ }
@@ -0,0 +1,72 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import type { Env } from "./types.ts";
4
+ import { registerPrivateBookingTools, registerPublicTools } from "./tool-registration.ts";
5
+
6
+ const DEFAULTS = {
7
+ PREFERRED_CINEMAS: "babylon-kreuzberg,delphi-filmpalast,delphi-lux,filmtheater-am-friedrichshain,kant-kino,kino-international,neues-off,odeon,passage,rollberg,yorck",
8
+ DEFAULT_FORMATS: "OmeU,OV,OmU",
9
+ COGNITO_USER_POOL: "eu-central-1_TIusy2VuG",
10
+ COGNITO_CLIENT_ID: "4m9hc0qk59mvcb4hfd6lep1262",
11
+ COGNITO_REGION: "eu-central-1",
12
+ VISTA_BASE: "https://uq8lgoj7z2.execute-api.eu-central-1.amazonaws.com/production/api/vista",
13
+ YORCK_AUTH_BASE: "https://rbfmu7cs19.execute-api.eu-central-1.amazonaws.com/production",
14
+ YORCK_BASE: "https://www.yorck.de",
15
+ PUBLIC_BASE_URL: "https://yorck-mcp.isiklimahir.workers.dev",
16
+ };
17
+
18
+ type KvRecord = { value: string; expiresAt?: number };
19
+
20
+ class MemoryKv {
21
+ private store = new Map<string, KvRecord>();
22
+
23
+ async get(key: string, type?: "text" | "json" | "arrayBuffer" | "stream") {
24
+ const record = this.store.get(key);
25
+ if (!record) return null;
26
+ if (record.expiresAt && Date.now() > record.expiresAt) {
27
+ this.store.delete(key);
28
+ return null;
29
+ }
30
+ if (type === "json") return JSON.parse(record.value);
31
+ if (type === "arrayBuffer") return new TextEncoder().encode(record.value).buffer;
32
+ return record.value;
33
+ }
34
+
35
+ async put(key: string, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: { expirationTtl?: number }) {
36
+ if (typeof value !== "string") throw new Error("local MCP MemoryKv only supports string values");
37
+ this.store.set(key, {
38
+ value,
39
+ expiresAt: options?.expirationTtl ? Date.now() + options.expirationTtl * 1000 : undefined,
40
+ });
41
+ }
42
+ }
43
+
44
+ function envFromProcess(): Env {
45
+ return {
46
+ CACHE: new MemoryKv() as unknown as KVNamespace,
47
+ MCP_OBJECT: undefined as unknown as DurableObjectNamespace,
48
+ PUBLIC_MCP_OBJECT: undefined as unknown as DurableObjectNamespace,
49
+ BROWSER: undefined as unknown as Fetcher,
50
+ PREFERRED_CINEMAS: process.env.YORCK_PREFERRED_CINEMAS || DEFAULTS.PREFERRED_CINEMAS,
51
+ DEFAULT_FORMATS: process.env.YORCK_DEFAULT_FORMATS || DEFAULTS.DEFAULT_FORMATS,
52
+ COGNITO_USER_POOL: process.env.COGNITO_USER_POOL || DEFAULTS.COGNITO_USER_POOL,
53
+ COGNITO_CLIENT_ID: process.env.COGNITO_CLIENT_ID || DEFAULTS.COGNITO_CLIENT_ID,
54
+ COGNITO_REGION: process.env.COGNITO_REGION || DEFAULTS.COGNITO_REGION,
55
+ VISTA_BASE: process.env.VISTA_BASE || DEFAULTS.VISTA_BASE,
56
+ YORCK_AUTH_BASE: process.env.YORCK_AUTH_BASE || DEFAULTS.YORCK_AUTH_BASE,
57
+ YORCK_BASE: process.env.YORCK_BASE || DEFAULTS.YORCK_BASE,
58
+ PUBLIC_BASE_URL: process.env.PUBLIC_BASE_URL || DEFAULTS.PUBLIC_BASE_URL,
59
+ YORCK_EMAIL: process.env.YORCK_EMAIL,
60
+ YORCK_PASSWORD: process.env.YORCK_PASSWORD,
61
+ YORCK_UNLIMITED_CARD: process.env.YORCK_UNLIMITED_CARD,
62
+ YORCK_MCP_AUTH_TOKEN: process.env.YORCK_MCP_AUTH_TOKEN,
63
+ };
64
+ }
65
+
66
+ export async function runLocalMcp(): Promise<void> {
67
+ const env = envFromProcess();
68
+ const server = new McpServer({ name: "yorck-local", version: "0.1.0" });
69
+ registerPublicTools(server, env);
70
+ registerPrivateBookingTools(server, env);
71
+ await server.connect(new StdioServerTransport());
72
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { McpAgent } from "agents/mcp";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import type { Env } from "./types.ts";
4
+ import { registerPrivateBookingTools, registerPublicTools } from "./tool-registration.ts";
5
+
6
+ export { registerPrivateBookingTools, registerPublicTools } from "./tool-registration.ts";
7
+
8
+ export class PublicYorckMcp extends McpAgent<Env> {
9
+ server = new McpServer({
10
+ name: "yorck-public",
11
+ version: "0.1.0",
12
+ });
13
+
14
+ async init() {
15
+ registerPublicTools(this.server, this.env);
16
+ }
17
+ }
18
+
19
+ export class YorckMcp extends McpAgent<Env> {
20
+ server = new McpServer({
21
+ name: "yorck-private",
22
+ version: "0.1.0",
23
+ });
24
+
25
+ async init() {
26
+ const env = this.env;
27
+ registerPublicTools(this.server, env);
28
+ registerPrivateBookingTools(this.server, env);
29
+ }
30
+ }
@@ -0,0 +1,228 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import type { Env, Showtime } from "./types.ts";
4
+ import { searchShowtimes, findFilmsByQuery, getComingSoon } from "./yorck/films.ts";
5
+ import { getCinemas } from "./yorck/cinemas.ts";
6
+ import { getSeatPlan, renderSeatPlanSvg } from "./yorck/seats.ts";
7
+ import { reserveUnlimited, cancelOrder, commitOrder } from "./yorck/booking.ts";
8
+ import { showtimeToIcs } from "./lib/ics.ts";
9
+ import { nowBerlinIso } from "./lib/tz.ts";
10
+
11
+ const FilmFiltersSchema = {
12
+ when: z
13
+ .union([
14
+ z.enum(["now", "tonight", "tomorrow", "weekend", "week"]),
15
+ z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
16
+ ])
17
+ .optional()
18
+ .describe("'tonight', 'tomorrow', 'weekend', 'week', or YYYY-MM-DD"),
19
+ cinemas: z
20
+ .array(z.string())
21
+ .optional()
22
+ .describe("cinema slugs, e.g. ['rollberg','delphi-lux']. Defaults to configured preferred cinemas."),
23
+ formats: z
24
+ .array(z.string())
25
+ .optional()
26
+ .describe("'OmeU' (English subs), 'OV' (no subs), 'OmU' (German subs), 'DF' (German dub). Default excludes DF."),
27
+ preferEnglish: z.boolean().optional().describe("bias toward OmeU > OV > OmU when sorting"),
28
+ genres: z.array(z.string()).optional().describe("e.g. ['Drama','Documentary']"),
29
+ fskMax: z.number().int().min(0).max(18).optional(),
30
+ runtimeMax: z.number().int().min(30).max(300).optional(),
31
+ after: z.string().regex(/^\d{1,2}:\d{2}$/).optional().describe("HH:MM, e.g. '18:00'"),
32
+ before: z.string().regex(/^\d{1,2}:\d{2}$/).optional(),
33
+ yorckPick: z.boolean().optional(),
34
+ district: z.array(z.string()).optional(),
35
+ query: z.string().optional().describe("text search across title and tagline"),
36
+ };
37
+
38
+ function project(showtimes: Showtime[], limit = 50): { now: string; count: number; truncated: boolean; showtimes: Showtime[] } {
39
+ return {
40
+ now: nowBerlinIso(),
41
+ count: showtimes.length,
42
+ truncated: showtimes.length > limit,
43
+ showtimes: showtimes.slice(0, limit),
44
+ };
45
+ }
46
+
47
+ function publicBaseUrl(env: Env): string {
48
+ return (env.PUBLIC_BASE_URL || "https://yorck-mcp.isiklimahir.workers.dev").replace(/\/$/, "");
49
+ }
50
+
51
+ export function registerPublicTools(server: McpServer, env: Env) {
52
+ server.tool(
53
+ "whats_on",
54
+ "Search public Yorck showtimes in Berlin with filters. Use this for everyday 'what is playing tonight / this weekend' queries.",
55
+ FilmFiltersSchema,
56
+ async (args) => {
57
+ const out = await searchShowtimes(env, args);
58
+ return {
59
+ content: [{ type: "text" as const, text: JSON.stringify(project(out), null, 2) }],
60
+ };
61
+ }
62
+ );
63
+
64
+ server.tool(
65
+ "find_film",
66
+ "Search for a Yorck film by title, director, or tagline. Returns matches with their slug for showtimes.",
67
+ { query: z.string().min(2), limit: z.number().int().min(1).max(20).optional() },
68
+ async ({ query, limit }) => {
69
+ const matches = await findFilmsByQuery(env, query, limit ?? 5);
70
+ return { content: [{ type: "text" as const, text: JSON.stringify(matches, null, 2) }] };
71
+ }
72
+ );
73
+
74
+ server.tool(
75
+ "showtimes",
76
+ "All upcoming sessions for a specific film, optionally filtered by cinema, format, or date.",
77
+ {
78
+ slug: z.string().describe("film slug from find_film"),
79
+ cinemas: z.array(z.string()).optional(),
80
+ formats: z.array(z.string()).optional(),
81
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
82
+ },
83
+ async ({ slug, cinemas, formats, date }) => {
84
+ const out = (await searchShowtimes(env, { cinemas, formats, date, query: undefined })).filter(
85
+ (s) => s.slug === slug
86
+ );
87
+ return { content: [{ type: "text" as const, text: JSON.stringify(project(out), null, 2) }] };
88
+ }
89
+ );
90
+
91
+ server.tool(
92
+ "coming_soon",
93
+ "Films opening at Yorck in the coming weeks.",
94
+ {},
95
+ async () => {
96
+ const upcoming = await getComingSoon(env);
97
+ return { content: [{ type: "text" as const, text: JSON.stringify(upcoming.slice(0, 30), null, 2) }] };
98
+ }
99
+ );
100
+
101
+ server.tool(
102
+ "cinemas",
103
+ "List Yorck cinemas with slugs, addresses, districts, and public metadata.",
104
+ {},
105
+ async () => {
106
+ const cinemas = await getCinemas(env);
107
+ return { content: [{ type: "text" as const, text: JSON.stringify(cinemas, null, 2) }] };
108
+ }
109
+ );
110
+
111
+ server.tool(
112
+ "seat_map",
113
+ "Returns the public seat plan for a session as an inline SVG image plus visible row and seat IDs. This tool does not reserve or book seats.",
114
+ {
115
+ sessionId: z.string().regex(/^\d{4}-\d+$/).describe("Yorck session id, e.g. '1003-5724'"),
116
+ },
117
+ async ({ sessionId }) => {
118
+ const [cinemaVistaId, sessionNum] = sessionId.split("-");
119
+ const cinema = await (async () => {
120
+ const all = await getCinemas(env);
121
+ return all.find((c) => c.vistaId === cinemaVistaId);
122
+ })();
123
+ if (!cinema) throw new Error(`unknown cinema for session ${sessionId}`);
124
+ const plan = await getSeatPlan(env, cinemaVistaId, sessionNum);
125
+ const svg = renderSeatPlanSvg(plan, {
126
+ title: `${cinema.name}`,
127
+ subtitle: `Session ${sessionId}`,
128
+ });
129
+ const rows = (plan.SeatLayoutData.Areas[0]?.Rows ?? []).map((row) => ({
130
+ physicalName: row.PhysicalName,
131
+ rowIndex: row.RowIndexZeroBased,
132
+ availableIds: (row.Seats ?? []).filter((seat) => seat.Status === 0).map((seat) => seat.Id),
133
+ }));
134
+ const dataUrl = "data:image/svg+xml;base64," + btoa(svg);
135
+ return {
136
+ content: [
137
+ { type: "text" as const, text: JSON.stringify({ sessionId, cinema: cinema.name, rows }, null, 2) },
138
+ { type: "image" as const, data: dataUrl, mimeType: "image/svg+xml" },
139
+ ],
140
+ };
141
+ }
142
+ );
143
+
144
+ server.tool(
145
+ "add_to_calendar",
146
+ "Generate an .ics calendar event for a public Yorck showtime.",
147
+ {
148
+ sessionId: z.string().regex(/^\d{4}-\d+$/),
149
+ slug: z.string().describe("film slug"),
150
+ },
151
+ async ({ sessionId, slug }) => {
152
+ const all = await searchShowtimes(env, { cinemas: undefined, formats: ["OmeU", "OV", "OmU", "DF"] });
153
+ const s = all.find((x) => x.sessionId === sessionId && x.slug === slug);
154
+ if (!s) throw new Error("session not found in current showtimes");
155
+ const cinemas = await getCinemas(env);
156
+ const cinema = cinemas.find((c) => c.slug === s.cinemaSlug);
157
+ const ics = showtimeToIcs(s, cinema?.address);
158
+ const downloadUrl = `${publicBaseUrl(env)}/v1/calendar/${encodeURIComponent(sessionId)}.ics?slug=${encodeURIComponent(slug)}`;
159
+ return {
160
+ content: [{
161
+ type: "text" as const,
162
+ text: JSON.stringify({
163
+ downloadUrl,
164
+ filename: `${slug}-${sessionId}.ics`,
165
+ note: "Open or import the downloadUrl in Apple Calendar, Google Calendar, Outlook, or any calendar app. The raw ICS is included below.",
166
+ ics,
167
+ }, null, 2),
168
+ }],
169
+ };
170
+ }
171
+ );
172
+ }
173
+
174
+ export function registerPrivateBookingTools(server: McpServer, env: Env) {
175
+ server.tool(
176
+ "book_session",
177
+ "Private tool. Reserve and book a specific seat using your Yorck Unlimited subscription. Pass cinemaSlug + sessionId + rowLabel + seatId. Will reserve, validate as Unlimited (€0), and if dryRun=false, commit. Default dryRun=true reserves only, returns preview, and does not finalize.",
178
+ {
179
+ sessionId: z.string().regex(/^\d{4}-\d+$/),
180
+ cinemaSlug: z.string(),
181
+ rowLabel: z.string().describe("e.g. '16' (the PhysicalName from seat_map)"),
182
+ seatId: z.string().describe("e.g. '17' (the seat Id from seat_map)"),
183
+ dryRun: z.boolean().default(true),
184
+ },
185
+ async ({ sessionId, cinemaSlug, rowLabel, seatId, dryRun }) => {
186
+ const r = await reserveUnlimited(env, { sessionId, cinemaSlug, rowLabel, seatId });
187
+ const summary = {
188
+ reserved: true,
189
+ approved: r.approved,
190
+ ticketTypeCode: r.ticketTypeCode,
191
+ orderTotal: r.order.orderTotalValueInCents / 100,
192
+ expiresAt: r.expiresAt,
193
+ userSessionId: r.order.userSessionId,
194
+ cinema: cinemaSlug,
195
+ seat: { row: rowLabel, seat: seatId },
196
+ film: r.order.sessions[0]?.filmTitle,
197
+ start: r.order.sessions[0]?.startTime,
198
+ };
199
+ if (dryRun) {
200
+ await cancelOrder(env, r.order.userSessionId);
201
+ return {
202
+ content: [
203
+ {
204
+ type: "text" as const,
205
+ text: `DRY-RUN ${summary.approved ? "✓" : "✗"} ${JSON.stringify(summary, null, 2)}\n(seat hold released for safety; call again with dryRun=false to actually book)`,
206
+ },
207
+ ],
208
+ };
209
+ }
210
+ const result = await commitOrder(env, r.order.userSessionId);
211
+ return {
212
+ content: [
213
+ { type: "text" as const, text: `Booked! ${JSON.stringify({ ...summary, commit: result }, null, 2)}` },
214
+ ],
215
+ };
216
+ }
217
+ );
218
+
219
+ server.tool(
220
+ "cancel_booking",
221
+ "Private tool. Release a held order by userSessionId. Use this if you want to abandon a reservation before it auto-expires.",
222
+ { userSessionId: z.string() },
223
+ async ({ userSessionId }) => {
224
+ await cancelOrder(env, userSessionId);
225
+ return { content: [{ type: "text" as const, text: `cancelled ${userSessionId}` }] };
226
+ }
227
+ );
228
+ }