tap2cal-mcp 0.0.0-stage → 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +101 -2
  3. package/index.js +293 -0
  4. package/package.json +48 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthias Aigner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,102 @@
1
- # Temporary Holding Version
1
+ # tap2cal-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [MCP](https://modelcontextprotocol.io) server for [Tap2Cal](https://tap2cal.com).
4
+
5
+ Lets an AI assistant turn an event into a **shareable add-to-calendar link**.
6
+ Anyone who opens the link saves the event to Google Calendar, Apple Calendar,
7
+ Outlook or any other calendar with one tap, no app and no account needed.
8
+
9
+ ```
10
+ You: Make a calendar link for our summer party, July 5, 6 to 10 pm in
11
+ Vienna, with RSVPs, and use the poster on my desktop as flyer.
12
+ Claude: [create_event, then set_flyer]
13
+ Here is your link: https://tap2cal.com/summer-party
14
+ ```
15
+
16
+ The assistant can do everything you can do in the app: create, change and
17
+ delete events, choose the link, disable earlier links, add a flyer and read
18
+ who accepted or declined.
19
+
20
+ ## Setup
21
+
22
+ Create a free account at [tap2cal.com/app](https://tap2cal.com/app/login), open
23
+ [your account](https://tap2cal.com/app/account) and create a key under
24
+ "API and AI assistants". Events created with it appear under My events.
25
+
26
+ ### Claude Code
27
+
28
+ ```bash
29
+ claude mcp add tap2cal -e TAP2CAL_API_KEY=t2c_YOUR_KEY -- npx -y tap2cal-mcp
30
+ ```
31
+
32
+ ### Claude Desktop
33
+
34
+ Add to `claude_desktop_config.json`:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "tap2cal": {
40
+ "command": "npx",
41
+ "args": ["-y", "tap2cal-mcp"],
42
+ "env": { "TAP2CAL_API_KEY": "t2c_YOUR_KEY" }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ ### Cursor, Zed and other MCP clients
49
+
50
+ Same shape: command `npx`, args `["-y", "tap2cal-mcp"]`, and `TAP2CAL_API_KEY`
51
+ in the environment.
52
+
53
+ ### Without installing anything
54
+
55
+ Clients that support remote servers can connect to the hosted server instead:
56
+ `https://mcp.tap2cal.com` with the header `Authorization: Bearer t2c_YOUR_KEY`.
57
+ This package adds one thing the hosted server can't do: `set_flyer` can read
58
+ an image file from your computer.
59
+
60
+ ## Tools
61
+
62
+ Wherever an event is needed, its id, any of its links or the full URL works.
63
+
64
+ | Tool | What it does |
65
+ |------|--------------|
66
+ | `create_event` | Creates an event and returns its link. Optional own link via `slug` |
67
+ | `list_my_events` | Lists your events with page views, calendar clicks and RSVP counts |
68
+ | `get_event` | Shows one event with all details and its earlier links |
69
+ | `update_event` | Changes only the fields you pass, including the link (`slug`) |
70
+ | `disable_old_link` | Turns off an earlier link after the link was changed |
71
+ | `list_rsvps` | Lists who accepted or declined, with the people they bring |
72
+ | `set_flyer` | Adds a flyer from a file on this computer or an image URL |
73
+ | `remove_flyer` | Removes the flyer |
74
+ | `delete_event` | Deletes an event and all its links. Can't be undone |
75
+
76
+ Event pages are public to anyone with the link and are deleted 90 days after
77
+ the event ends.
78
+
79
+ ## Configuration
80
+
81
+ | Variable | Required | Default | Notes |
82
+ |----------|----------|---------|-------|
83
+ | `TAP2CAL_API_KEY` | yes | (none) | Your API key, starts with `t2c_` |
84
+ | `TAP2CAL_API_URL` | no | `https://api.tap2cal.com/v1` | Only for testing |
85
+ | `TAP2CAL_TIMEOUT_MS` | no | `30000` | Request timeout |
86
+
87
+ The key is never a tool argument and never part of a tool result, so it does
88
+ not end up in the conversation.
89
+
90
+ ## Limits
91
+
92
+ Up to 50 events and 20 images per account per day. Errors come back with an
93
+ explanation the assistant can pass on, for example when a link is already taken.
94
+
95
+ ## Links
96
+
97
+ - [Developer documentation](https://tap2cal.com/developers/)
98
+ - [OpenAPI description](https://tap2cal.com/openapi.json)
99
+
100
+ ## License
101
+
102
+ MIT
package/index.js ADDED
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tap2Cal MCP server.
4
+ *
5
+ * Lets an assistant turn an event into a shareable add-to-calendar link and
6
+ * manage it afterwards: change it, change its link, read RSVPs, add a flyer.
7
+ * It talks to the Tap2Cal REST API with the user's API key.
8
+ *
9
+ * Configuration:
10
+ * TAP2CAL_API_KEY=t2c_... from https://tap2cal.com/app/account
11
+ *
12
+ * The key never goes into a tool argument or a tool result, so it does not
13
+ * travel through the transcript.
14
+ *
15
+ * stdio note: stdout is the protocol channel. Everything diagnostic goes to
16
+ * stderr, or the client sees corrupted frames.
17
+ */
18
+ import { readFile, stat } from "node:fs/promises";
19
+ import { McpServer } from "@modelcontextprotocol/server";
20
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
21
+ import * as z from "zod/v4";
22
+
23
+ const KEY = process.env.TAP2CAL_API_KEY;
24
+ const BASE = (process.env.TAP2CAL_API_URL || "https://api.tap2cal.com/v1").replace(/\/+$/, "");
25
+ const TIMEOUT_MS = Number(process.env.TAP2CAL_TIMEOUT_MS || 30_000);
26
+ const FLYER_MAX_BYTES = 8 * 1024 * 1024;
27
+ const LANGUAGES = ["en", "de", "es", "fr", "it", "pt"];
28
+
29
+ if (!KEY) {
30
+ console.error(
31
+ "tap2cal-mcp: TAP2CAL_API_KEY is not set.\n" +
32
+ "Create a free account at https://tap2cal.com/app/login, open your account,\n" +
33
+ "create a key under \"API and AI assistants\" and set TAP2CAL_API_KEY=t2c_...",
34
+ );
35
+ process.exit(1);
36
+ }
37
+ let parsed;
38
+ try {
39
+ parsed = new URL(BASE);
40
+ } catch {
41
+ console.error(`tap2cal-mcp: TAP2CAL_API_URL is not a URL: ${BASE}`);
42
+ process.exit(1);
43
+ }
44
+ // Plain http is allowed only on loopback, where nothing leaves the machine;
45
+ // anywhere else the API key would travel in clear text.
46
+ const isLoopback = ["localhost", "127.0.0.1", "[::1]", "::1"].includes(parsed.hostname);
47
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) {
48
+ console.error(`tap2cal-mcp: TAP2CAL_API_URL must use https (http only on localhost): ${BASE}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const redact = (text) => String(text).split(KEY).join("<your API key>");
53
+
54
+ // Error codes of the API, explained so the assistant can tell the user what to do.
55
+ const EXPLAIN = {
56
+ unauthenticated: "The API key is wrong or was deleted. Create a new one at https://tap2cal.com/app/account.",
57
+ not_found: "No event of this account matches. Use list_my_events to see the events and their links.",
58
+ invalid_name: "The name is missing or longer than 120 characters.",
59
+ invalid_date: "Dates must be YYYY-MM-DD.",
60
+ invalid_time: "Times must be HH:MM, and a start time needs an end time.",
61
+ invalid_date_range: "The end must be after the start.",
62
+ invalid_time_zone: "The time zone must be an IANA name such as Europe/Vienna.",
63
+ invalid_link: "The link must start with http:// or https:// and be at most 500 characters.",
64
+ invalid_color: "Colors must be #rrggbb.",
65
+ invalid_slug: "The link needs at least 3 characters (a to z, digits, hyphens) and can't be a reserved word.",
66
+ slug_taken: "This link is already used by another event. Pick a different one.",
67
+ too_many_slug_changes: "This event already had 5 new links, which is the limit.",
68
+ too_many_events: "The daily limit of new events for this account is reached. Try again tomorrow.",
69
+ too_many_edits: "The daily limit of changes for this account is reached. Try again tomorrow.",
70
+ too_many_flyers: "The daily limit of flyer uploads for this account is reached. Try again tomorrow.",
71
+ service_busy: "Tap2Cal is busy right now. Try again later.",
72
+ invalid_image: "The file is not an image Tap2Cal can read. Use JPEG, PNG or WebP.",
73
+ flyer_too_large: "The image is larger than 8 MB.",
74
+ flyer_rejected: "The image was rejected by the automatic check and is not published.",
75
+ };
76
+
77
+ class ApiError extends Error {}
78
+
79
+ async function api(method, path, { json, body, contentType } = {}) {
80
+ const headers = { Authorization: `Bearer ${KEY}` };
81
+ if (json !== undefined) headers["Content-Type"] = "application/json";
82
+ if (contentType) headers["Content-Type"] = contentType;
83
+
84
+ let res;
85
+ let text;
86
+ try {
87
+ res = await fetch(`${BASE}${path}`, {
88
+ method,
89
+ headers,
90
+ body: json !== undefined ? JSON.stringify(json) : body,
91
+ signal: AbortSignal.timeout(TIMEOUT_MS),
92
+ });
93
+ text = await res.text();
94
+ } catch (err) {
95
+ const reason = err?.name === "TimeoutError"
96
+ ? `no response within ${TIMEOUT_MS} ms`
97
+ : redact(err?.message || String(err));
98
+ throw new ApiError(`Could not reach Tap2Cal: ${reason}`);
99
+ }
100
+
101
+ let data = null;
102
+ try { data = text ? JSON.parse(text) : null; } catch { /* handled below */ }
103
+ if (!res.ok) {
104
+ const code = data?.error;
105
+ const hint = code && EXPLAIN[code] ? ` ${EXPLAIN[code]}` : "";
106
+ throw new ApiError(`Tap2Cal returned HTTP ${res.status}${code ? ` (${code})` : ""}.${hint}`);
107
+ }
108
+ return data;
109
+ }
110
+
111
+ const ok = (data) => ({ content: [{ type: "text", text: JSON.stringify(data, null, 2) }] });
112
+ const run = (action) => async (args) => {
113
+ try {
114
+ return ok(await action(args));
115
+ } catch (err) {
116
+ const text = err instanceof ApiError ? err.message : `Error: ${redact(err?.message || err)}`;
117
+ return { isError: true, content: [{ type: "text", text }] };
118
+ }
119
+ };
120
+ const eventPath = (event) => `/events/${encodeURIComponent(String(event).trim())}`;
121
+ const pick = (args, keys) => Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]));
122
+
123
+ const EVENT_FIELDS = [
124
+ "name", "startDate", "endDate", "startTime", "endTime", "timeZone", "location", "subtitle",
125
+ "description", "link", "accentColor", "backgroundColor", "lang", "rsvpEnabled", "slug",
126
+ ];
127
+ const eventArg = z.string().describe("The event: its id, any of its links or the full URL");
128
+ const clearable = (text) => z.string().nullable().optional()
129
+ .describe(`${text}. Pass null or an empty string to remove it`);
130
+
131
+ const server = new McpServer({ name: "tap2cal", version: "1.0.0" });
132
+
133
+ server.registerTool("create_event", {
134
+ title: "Create a calendar link",
135
+ description:
136
+ "Creates a public page for an event with a shareable link. Anyone who opens the " +
137
+ "link can save the event to Google Calendar, Apple Calendar, Outlook or any other " +
138
+ "calendar. Returns the event with its link in url. Anyone with the link can see " +
139
+ "the event details, and the event is deleted 90 days after it ends.",
140
+ inputSchema: z.object({
141
+ name: z.string().describe("Event name, up to 120 characters"),
142
+ startDate: z.string().describe("Start date as YYYY-MM-DD"),
143
+ endDate: z.string().optional().describe("End date as YYYY-MM-DD, defaults to the start date"),
144
+ startTime: z.string().optional().describe("Start time as HH:MM. Leave out for an all-day event"),
145
+ endTime: z.string().optional().describe("End time as HH:MM, required with a start time"),
146
+ timeZone: z.string().describe("IANA time zone, e.g. Europe/Vienna"),
147
+ location: z.string().optional().describe("Location, up to 200 characters"),
148
+ subtitle: z.string().optional().describe("Short subtitle, up to 160 characters"),
149
+ description: z.string().optional().describe("Description, up to 1000 characters"),
150
+ link: z.string().optional().describe("Related https link, e.g. tickets"),
151
+ accentColor: z.string().optional().describe("Button color as #rrggbb"),
152
+ backgroundColor: z.string().optional().describe("Page background color as #rrggbb"),
153
+ lang: z.enum(LANGUAGES).optional().describe("Language of the link preview"),
154
+ rsvpEnabled: z.boolean().optional().describe("Let guests accept or decline with their name"),
155
+ slug: z.string().optional().describe(
156
+ "Own link, e.g. summer-party for tap2cal.com/summer-party. At least 3 characters. " +
157
+ "Leave out for a link made from the name"),
158
+ }),
159
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
160
+ }, run(async (args) => (await api("POST", "/events", { json: pick(args, EVENT_FIELDS) })).event));
161
+
162
+ server.registerTool("list_my_events", {
163
+ title: "List my calendar links",
164
+ description:
165
+ "Lists the events of this Tap2Cal account with their links, page views, calendar " +
166
+ "button clicks and RSVP counts, newest start date first.",
167
+ inputSchema: z.object({}),
168
+ annotations: { readOnlyHint: true, openWorldHint: false },
169
+ }, run(() => api("GET", "/events")));
170
+
171
+ server.registerTool("get_event", {
172
+ title: "Show a calendar link",
173
+ description:
174
+ "Returns one event with all its details, its current link, earlier links that still " +
175
+ "work, page views, calendar button clicks and RSVP counts.",
176
+ inputSchema: z.object({ event: eventArg }),
177
+ annotations: { readOnlyHint: true, openWorldHint: false },
178
+ }, run(async ({ event }) => (await api("GET", eventPath(event))).event));
179
+
180
+ server.registerTool("update_event", {
181
+ title: "Change a calendar link",
182
+ description:
183
+ "Changes an event. Only the fields you pass change. Moving the start date without an " +
184
+ "end date keeps the length of the event. Passing slug changes the link; the earlier " +
185
+ "link keeps working until it is disabled with disable_old_link.",
186
+ inputSchema: z.object({
187
+ event: eventArg,
188
+ name: z.string().optional().describe("Event name, up to 120 characters"),
189
+ startDate: z.string().optional().describe("Start date as YYYY-MM-DD"),
190
+ endDate: z.string().optional().describe("End date as YYYY-MM-DD"),
191
+ startTime: clearable("Start time as HH:MM. Removing it makes the event all-day"),
192
+ endTime: clearable("End time as HH:MM, required with a start time"),
193
+ timeZone: z.string().optional().describe("IANA time zone, e.g. Europe/Vienna"),
194
+ location: clearable("Location, up to 200 characters"),
195
+ subtitle: clearable("Short subtitle, up to 160 characters"),
196
+ description: clearable("Description, up to 1000 characters"),
197
+ link: clearable("Related https link, e.g. tickets"),
198
+ accentColor: clearable("Button color as #rrggbb"),
199
+ backgroundColor: clearable("Page background color as #rrggbb"),
200
+ lang: z.enum(LANGUAGES).optional().describe("Language of the link preview"),
201
+ rsvpEnabled: z.boolean().optional().describe("Let guests accept or decline with their name"),
202
+ slug: z.string().optional().describe(
203
+ "New link, e.g. summer-party for tap2cal.com/summer-party. At least 3 characters, " +
204
+ "up to 5 new links per event"),
205
+ }),
206
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
207
+ }, run(async ({ event, ...changes }) =>
208
+ (await api("PATCH", eventPath(event), { json: pick(changes, EVENT_FIELDS) })).event));
209
+
210
+ server.registerTool("disable_old_link", {
211
+ title: "Disable an earlier link",
212
+ description:
213
+ "Disables an earlier link of an event, so it shows that the link is no longer valid. " +
214
+ "The current link can't be disabled. Choosing the link again with update_event turns it back on.",
215
+ inputSchema: z.object({
216
+ event: eventArg,
217
+ link: z.string().describe("The earlier link to disable, e.g. summer-party-x7k2 or its full URL"),
218
+ }),
219
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
220
+ }, run(async ({ event, link }) => {
221
+ const slug = String(link).trim().replace(/^(https?:\/\/)?[^/]*tap2cal\.com\//i, "");
222
+ return (await api("DELETE", `${eventPath(event)}/links/${encodeURIComponent(slug)}`)).event;
223
+ }));
224
+
225
+ server.registerTool("list_rsvps", {
226
+ title: "List RSVPs",
227
+ description: "Lists the guests who accepted or declined an event, with the number of people each guest brings along.",
228
+ inputSchema: z.object({ event: eventArg }),
229
+ annotations: { readOnlyHint: true, openWorldHint: false },
230
+ }, run(({ event }) => api("GET", `${eventPath(event)}/rsvps`)));
231
+
232
+ async function readImage(imagePath, imageUrl) {
233
+ if (imagePath) {
234
+ const info = await stat(imagePath);
235
+ if (info.size > FLYER_MAX_BYTES) throw new ApiError("The image is larger than 8 MB.");
236
+ return readFile(imagePath);
237
+ }
238
+ if (!imageUrl || !/^https?:\/\//i.test(imageUrl)) {
239
+ throw new ApiError("Pass imagePath (a file on this computer) or imageUrl (http or https).");
240
+ }
241
+ let res;
242
+ try {
243
+ res = await fetch(imageUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
244
+ } catch (err) {
245
+ throw new ApiError(`Could not download the image: ${err?.message || err}`);
246
+ }
247
+ if (!res.ok) throw new ApiError(`Could not download the image: HTTP ${res.status}`);
248
+ const data = Buffer.from(await res.arrayBuffer());
249
+ if (data.length > FLYER_MAX_BYTES) throw new ApiError("The image is larger than 8 MB.");
250
+ return data;
251
+ }
252
+
253
+ server.registerTool("set_flyer", {
254
+ title: "Add a flyer",
255
+ description:
256
+ "Adds or replaces the flyer of an event. It is shown on the event page and in link " +
257
+ "previews. Pass a file on this computer or an image URL, JPEG, PNG or WebP up to 8 MB. " +
258
+ "Images are checked automatically before they are published.",
259
+ inputSchema: z.object({
260
+ event: eventArg,
261
+ imagePath: z.string().optional().describe("Path of an image file on this computer"),
262
+ imageUrl: z.string().optional().describe("URL of an image to download"),
263
+ }),
264
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
265
+ }, run(async ({ event, imagePath, imageUrl }) => {
266
+ const image = await readImage(imagePath, imageUrl);
267
+ return (await api("POST", `${eventPath(event)}/flyer`, {
268
+ body: image, contentType: "application/octet-stream",
269
+ })).event;
270
+ }));
271
+
272
+ server.registerTool("remove_flyer", {
273
+ title: "Remove the flyer",
274
+ description: "Removes the flyer of an event. The page and link preview go back to the design without image.",
275
+ inputSchema: z.object({ event: eventArg }),
276
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
277
+ }, run(async ({ event }) => (await api("DELETE", `${eventPath(event)}/flyer`)).event));
278
+
279
+ server.registerTool("delete_event", {
280
+ title: "Delete a calendar link",
281
+ description:
282
+ "Deletes an event with its page, all its links, its flyer and its RSVPs. The links " +
283
+ "stop working right away. This can't be undone.",
284
+ inputSchema: z.object({ event: eventArg }),
285
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
286
+ }, run(async ({ event }) => {
287
+ await api("DELETE", eventPath(event));
288
+ return { deleted: true };
289
+ }));
290
+
291
+ const transport = new StdioServerTransport();
292
+ await server.connect(transport);
293
+ console.error(`tap2cal-mcp ready, API ${BASE}`);
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "tap2cal-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "mcpName": "io.github.MatthiasMAigner/tap2cal-mcp",
5
+ "description": "MCP server for Tap2Cal, lets an AI assistant turn events into shareable add-to-calendar links.",
6
+ "type": "module",
7
+ "bin": {
8
+ "tap2cal-mcp": "index.js"
9
+ },
10
+ "main": "./index.js",
11
+ "files": [
12
+ "index.js",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "test": "node test.js"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "calendar",
25
+ "add-to-calendar",
26
+ "ics",
27
+ "google-calendar",
28
+ "apple-calendar",
29
+ "outlook",
30
+ "events",
31
+ "rsvp",
32
+ "tap2cal",
33
+ "claude",
34
+ "stdio"
35
+ ],
36
+ "license": "MIT",
37
+ "author": "Aigner Software e.U.",
38
+ "homepage": "https://tap2cal.com/developers/",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/MatthiasMAigner/tap2cal-mcp.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/MatthiasMAigner/tap2cal-mcp/issues"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/server": "^2.3.1",
48
+ "zod": "^4.0.0"
49
+ }
50
+ }