From d372dbf7bbc8ab2afe026d15858643e0177ccca6 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 15 Apr 2026 21:35:54 -0300 Subject: [PATCH 01/17] fix(mcp-server): drop buildCommand from vercel.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vercel was running our Docker-oriented "bun run build" (which produces dist/) and then erroring with "No Output Directory named 'public' found" because no public/ exists. The MCP server on Vercel only needs the serverless function at api/index.ts — the @vercel/node builder compiles it automatically; we don't need a separate build step. Removing buildCommand, installCommand, and framework lets Vercel auto-detect bun (via bun.lock) and treat the project as serverless-only, which is what we actually want. This change was part of #78 but was dropped during the squash-merge. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mcp-server/vercel.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/mcp-server/vercel.json b/apps/mcp-server/vercel.json index 31328e7..3dbf55a 100644 --- a/apps/mcp-server/vercel.json +++ b/apps/mcp-server/vercel.json @@ -1,8 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "buildCommand": "bun run build", - "installCommand": "bun install", - "framework": null, "rewrites": [ { "source": "/(.*)", "destination": "/api/index" } ], From 20d02dbeae6eb7ea0ee170eb9067776fcb941fb8 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 15 Apr 2026 21:45:56 -0300 Subject: [PATCH 02/17] fix(mcp-server): move Vercel handler to src/, use JS wrapper in api/ The previous setup had api/index.ts with heavy imports from @modelcontextprotocol/sdk. @vercel/node's TypeScript compilation hung indefinitely on it (the SDK types OOM tsc even locally with a 4 GB heap). Move the handler to src/vercel-handler.ts so it's compiled to plain JavaScript by our existing `bun run build` step, and replace api/index.ts with a one-line ESM wrapper that re-exports the compiled default from ../dist/vercel-handler.js. @vercel/node now sees only plain JS in api/ and bundles in milliseconds. vercel.json: - Add buildCommand: "bun run build" so dist/ is produced before functions are bundled - Add outputDirectory: "." so Vercel stops expecting public/ - Register the function under its new path (api/index.js) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mcp-server/api/index.js | 5 +++++ .../{api/index.ts => src/vercel-handler.ts} | 22 ++++++++++++------- apps/mcp-server/vercel.json | 4 +++- 3 files changed, 22 insertions(+), 9 deletions(-) create mode 100644 apps/mcp-server/api/index.js rename apps/mcp-server/{api/index.ts => src/vercel-handler.ts} (89%) diff --git a/apps/mcp-server/api/index.js b/apps/mcp-server/api/index.js new file mode 100644 index 0000000..4a2d458 --- /dev/null +++ b/apps/mcp-server/api/index.js @@ -0,0 +1,5 @@ +// Thin JS wrapper — imports the pre-compiled handler from dist/ so @vercel/node +// does not have to run TypeScript over the heavy imports in src/vercel-handler.ts. +// The `bun run build` step (executed by Vercel before bundling functions) +// produces dist/vercel-handler.js. +export { default } from "../dist/vercel-handler.js"; diff --git a/apps/mcp-server/api/index.ts b/apps/mcp-server/src/vercel-handler.ts similarity index 89% rename from apps/mcp-server/api/index.ts rename to apps/mcp-server/src/vercel-handler.ts index ff4e13f..9fdbf6e 100644 --- a/apps/mcp-server/api/index.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -2,11 +2,11 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { authContext } from "../src/auth/context.js"; +import { authContext } from "./auth/context.js"; import { buildAuthorizationServerMetadata, buildProtectedResourceMetadata, -} from "../src/auth/oauth-metadata.js"; +} from "./auth/oauth-metadata.js"; import { handleAuthorize, handleCallback, @@ -15,12 +15,12 @@ import { handleToken, resolveCalAuthHeaders, type OAuthConfig, -} from "../src/auth/oauth-handlers.js"; -import { loadConfig, type HttpConfig } from "../src/config.js"; -import { registerTools } from "../src/register-tools.js"; -import { SERVER_INSTRUCTIONS } from "../src/server-instructions.js"; -import { initDb, sql } from "../src/storage/db.js"; -import { countRegisteredClients } from "../src/storage/token-store.js"; +} from "./auth/oauth-handlers.js"; +import { loadConfig, type HttpConfig } from "./config.js"; +import { registerTools } from "./register-tools.js"; +import { SERVER_INSTRUCTIONS } from "./server-instructions.js"; +import { initDb, sql } from "./storage/db.js"; +import { countRegisteredClients } from "./storage/token-store.js"; /** * Vercel serverless entry point. @@ -29,6 +29,12 @@ import { countRegisteredClients } from "../src/storage/token-store.js"; * (`sessionIdGenerator: undefined`) and token/OAuth state lives in Postgres. * There are no setInterval loops, in-memory session maps, or graceful-shutdown * hooks because the runtime manages lifecycle for us. + * + * This module lives under `src/` (not `api/`) so it gets compiled by our + * `bun run build` step into `dist/vercel-handler.js`. The Vercel function + * at `api/index.js` is a thin JS wrapper re-exporting from the compiled + * output, which avoids @vercel/node having to run TypeScript over the + * heavy `@modelcontextprotocol/sdk` types (which OOMs in practice). */ let cachedConfig: HttpConfig | undefined; diff --git a/apps/mcp-server/vercel.json b/apps/mcp-server/vercel.json index 3dbf55a..22d7946 100644 --- a/apps/mcp-server/vercel.json +++ b/apps/mcp-server/vercel.json @@ -1,10 +1,12 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "bun run build", + "outputDirectory": ".", "rewrites": [ { "source": "/(.*)", "destination": "/api/index" } ], "functions": { - "api/index.ts": { + "api/index.js": { "maxDuration": 60 } } From 8443e2c4e2f9b44bb4f74ad9dfbb23a42d5f122c Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 15 Apr 2026 21:50:43 -0300 Subject: [PATCH 03/17] chore: trigger vercel deploy From c272bbbaa366f25683c02ddd1eb850acf796bc88 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 15 Apr 2026 21:52:21 -0300 Subject: [PATCH 04/17] fix(mcp-server): force MCP_TRANSPORT=http in the Vercel handler The Vercel handler is only ever used for HTTP transport, but loadConfig() defaults to stdio mode when MCP_TRANSPORT is unset, which then errors on the missing CAL_API_KEY. Set MCP_TRANSPORT=http on the process env before loading so operators do not have to remember to configure it in the Vercel dashboard. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mcp-server/src/vercel-handler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mcp-server/src/vercel-handler.ts b/apps/mcp-server/src/vercel-handler.ts index 9fdbf6e..2a62cf2 100644 --- a/apps/mcp-server/src/vercel-handler.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -40,6 +40,9 @@ import { countRegisteredClients } from "./storage/token-store.js"; let cachedConfig: HttpConfig | undefined; function getConfig(): HttpConfig { if (cachedConfig) return cachedConfig; + // This handler only runs on Vercel, which is always HTTP mode. Force the + // transport so operators do not have to remember to set MCP_TRANSPORT=http. + process.env.MCP_TRANSPORT = "http"; const config = loadConfig(); if (config.transport !== "http") { throw new Error("MCP_TRANSPORT must be 'http' on Vercel"); From abf6a910ad75870bfea215dfc49e1d39d3dcfab7 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 15 Apr 2026 22:03:10 -0300 Subject: [PATCH 05/17] chore: re-trigger vercel deploy From 35afe0f26e9f760bd177b7622d109e5a59b083e2 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 15 Apr 2026 22:07:40 -0300 Subject: [PATCH 06/17] fix(mcp-server): bind sql to pool so tagged template keeps its this `export const sql = pool.sql;` destructured the tagged-template method off the pool, losing the `this` binding. On Vercel that surfaced as "Cannot read properties of undefined (reading 'connectionString')" because @vercel/postgres reads `this.config.connectionString` lazily at call time. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mcp-server/src/storage/db.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mcp-server/src/storage/db.ts b/apps/mcp-server/src/storage/db.ts index 706d3fd..cf7bb08 100644 --- a/apps/mcp-server/src/storage/db.ts +++ b/apps/mcp-server/src/storage/db.ts @@ -4,7 +4,10 @@ export const pool = createPool({ connectionString: process.env.DATABASE_URL, }); -export const sql = pool.sql; +// Bind so the tagged template keeps its `this` — destructuring `pool.sql` +// loses the binding and @vercel/postgres then reads `connectionString` off +// `undefined` at call time. +export const sql = pool.sql.bind(pool); let initialized = false; From 079948165de5199268e33597cc36518516729445 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 15 Apr 2026 22:29:57 -0300 Subject: [PATCH 07/17] feat(mcp-server): forward OAuth scope to Cal.com authorize URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cal.com's OAuthClient (user-facing) rejects authorize requests that omit the scope parameter: "scope parameter is required for this OAuth client". Add a new CAL_OAUTH_SCOPES env var (space-separated) that defaults to the full set of User-level scopes the MCP tools rely on (event types, bookings, schedules, apps, profile — read + write). Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mcp-server/src/auth/oauth-handlers.ts | 5 +++++ apps/mcp-server/src/config.ts | 6 ++++++ apps/mcp-server/src/index.ts | 1 + apps/mcp-server/src/vercel-handler.ts | 1 + 4 files changed, 13 insertions(+) diff --git a/apps/mcp-server/src/auth/oauth-handlers.ts b/apps/mcp-server/src/auth/oauth-handlers.ts index ce83fa4..f9fda02 100644 --- a/apps/mcp-server/src/auth/oauth-handlers.ts +++ b/apps/mcp-server/src/auth/oauth-handlers.ts @@ -29,6 +29,8 @@ export interface OAuthConfig { calApiBaseUrl: string; /** Cal.com app base URL for authorize redirect (default: https://app.cal.com) */ calAppBaseUrl?: string; + /** Space-separated Cal.com OAuth scopes (e.g. "BOOKING_READ BOOKING_WRITE") */ + calOAuthScopes?: string; } const MAX_BODY_SIZE = 1024 * 1024; // 1 MB @@ -186,6 +188,9 @@ export async function handleAuthorize( calAuthUrl.searchParams.set("code_challenge_method", "S256"); } calAuthUrl.searchParams.set("response_type", "code"); + if (config.calOAuthScopes) { + calAuthUrl.searchParams.set("scope", config.calOAuthScopes); + } res.writeHead(302, { Location: calAuthUrl.toString() }); res.end(); diff --git a/apps/mcp-server/src/config.ts b/apps/mcp-server/src/config.ts index 07d5720..1f542e7 100644 --- a/apps/mcp-server/src/config.ts +++ b/apps/mcp-server/src/config.ts @@ -50,6 +50,11 @@ const httpSchema = baseSchema.extend({ .regex(/^[0-9a-fA-F]+$/, "TOKEN_ENCRYPTION_KEY must be valid hex"), serverUrl: z.string().url("MCP_SERVER_URL must be a valid URL"), databaseUrl: z.string().min(1, "DATABASE_URL is required for HTTP mode"), + calOAuthScopes: z + .string() + .default( + "EVENT_TYPE_READ EVENT_TYPE_WRITE BOOKING_READ BOOKING_WRITE SCHEDULE_READ SCHEDULE_WRITE APPS_READ APPS_WRITE PROFILE_READ PROFILE_WRITE", + ), rateLimitWindowMs: z.coerce.number().int().positive().default(60_000), rateLimitMax: z.coerce.number().int().positive().default(30), maxSessions: z.coerce.number().int().positive().default(10_000), @@ -84,6 +89,7 @@ function readEnv(): Record { tokenEncryptionKey: process.env.TOKEN_ENCRYPTION_KEY || undefined, serverUrl: process.env.MCP_SERVER_URL || undefined, databaseUrl: process.env.DATABASE_URL || undefined, + calOAuthScopes: process.env.CAL_OAUTH_SCOPES || undefined, rateLimitWindowMs: process.env.RATE_LIMIT_WINDOW_MS || undefined, rateLimitMax: process.env.RATE_LIMIT_MAX || undefined, maxSessions: process.env.MAX_SESSIONS || undefined, diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index 35c972b..1b30d09 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -26,6 +26,7 @@ async function main(): Promise { calOAuthClientSecret: httpConfig.calOAuthClientSecret, calApiBaseUrl: httpConfig.calApiBaseUrl, calAppBaseUrl: httpConfig.calAppBaseUrl, + calOAuthScopes: httpConfig.calOAuthScopes, }, rateLimitWindowMs: httpConfig.rateLimitWindowMs, rateLimitMax: httpConfig.rateLimitMax, diff --git a/apps/mcp-server/src/vercel-handler.ts b/apps/mcp-server/src/vercel-handler.ts index 2a62cf2..9e80cd9 100644 --- a/apps/mcp-server/src/vercel-handler.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -69,6 +69,7 @@ function oauthConfigFromHttpConfig(config: HttpConfig): OAuthConfig { calOAuthClientSecret: config.calOAuthClientSecret, calApiBaseUrl: config.calApiBaseUrl, calAppBaseUrl: config.calAppBaseUrl, + calOAuthScopes: config.calOAuthScopes, }; } From 29c484bfe947b9f765401593b69627f4ba695a68 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 15 Apr 2026 22:47:23 -0300 Subject: [PATCH 08/17] fix(mcp-server): echo request Origin in CORS for credentialed requests Browsers reject Access-Control-Allow-Origin: * when the request carries an Authorization header (credentialed request). Echo the request's Origin header instead and add Access-Control-Allow-Credentials. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mcp-server/src/vercel-handler.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/mcp-server/src/vercel-handler.ts b/apps/mcp-server/src/vercel-handler.ts index 9e80cd9..4babaa7 100644 --- a/apps/mcp-server/src/vercel-handler.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -73,13 +73,16 @@ function oauthConfigFromHttpConfig(config: HttpConfig): OAuthConfig { }; } -function setCorsHeaders(res: ServerResponse, corsOrigin: string | undefined): void { - const origin = corsOrigin ?? "*"; +function setCorsHeaders(req: IncomingMessage, res: ServerResponse, corsOrigin: string | undefined): void { + // Credentialed requests (Authorization header) require an explicit origin, + // not "*". Fall back to echoing the request's Origin header. + const origin = corsOrigin ?? req.headers.origin ?? "*"; res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id"); res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); - if (origin !== "*") res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + res.setHeader("Vary", "Origin"); } function jsonError(res: ServerResponse, status: number, error: string, description?: string): void { @@ -92,7 +95,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) const oauthConfig = oauthConfigFromHttpConfig(config); await ensureDb(); - setCorsHeaders(res, config.corsOrigin); + setCorsHeaders(req, res, config.corsOrigin); if (req.method === "OPTIONS") { res.writeHead(204); From 0c4285366796511e590ec6ba965e3cc662f59952 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 15 Apr 2026 23:21:10 -0300 Subject: [PATCH 09/17] fix(mcp-server): use JSON response mode on Vercel to avoid 60s SSE timeout The default StreamableHTTP transport uses SSE streaming (ReadableStream that stays open until all events are flushed). On Vercel serverless, that stream never closes naturally, causing every POST /mcp to time out after the 60-second function limit. Setting enableJsonResponse: true switches to plain-JSON mode: the transport resolves immediately after processing each JSON-RPC request and writes a single JSON body, which completes well within the function time limit. Co-Authored-By: Claude Sonnet 4.6 --- apps/mcp-server/src/vercel-handler.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/mcp-server/src/vercel-handler.ts b/apps/mcp-server/src/vercel-handler.ts index 4babaa7..be4b4c8 100644 --- a/apps/mcp-server/src/vercel-handler.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -189,7 +189,14 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) return; } - const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + // enableJsonResponse: true → transport returns a plain JSON response instead of keeping + // an SSE stream open. Vercel serverless functions cannot hold long-lived streams, so + // the default SSE mode times out after 60 s. JSON mode resolves as soon as the server + // finishes processing each JSON-RPC request, well within the function time limit. + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); const server = new McpServer( { name: "calcom-mcp-server", version: "0.1.0" }, { instructions: SERVER_INSTRUCTIONS }, From 1254193dbdbb33e0c0f2a86f7fe19ac8198da103 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 15 Apr 2026 23:34:36 -0300 Subject: [PATCH 10/17] fix(mcp-server): allow mcp-protocol-version header in CORS and block GET SSE on Vercel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues caused the MCP client to get stuck after OAuth: 1. CORS preflight failure: the MCP client sends `mcp-protocol-version` and `last-event-id` as custom headers on every request after initialization. These were missing from Access-Control-Allow-Headers, so the browser's preflight rejected any request that included them (CORS error in DevTools). 2. Pending GET /mcp forever: after the `initialized` notification is acknowledged, the MCP client opens a GET SSE stream for server-initiated messages. On Vercel, that stream can never close (no persistent connections), so it stayed pending until the 60 s function timeout. Per the MCP spec, a 405 response to GET tells the client "SSE not supported here" and it falls back gracefully to POST-only mode — no error thrown. Co-Authored-By: Claude Sonnet 4.6 --- apps/mcp-server/src/vercel-handler.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/mcp-server/src/vercel-handler.ts b/apps/mcp-server/src/vercel-handler.ts index be4b4c8..069c9a6 100644 --- a/apps/mcp-server/src/vercel-handler.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -79,7 +79,13 @@ function setCorsHeaders(req: IncomingMessage, res: ServerResponse, corsOrigin: s const origin = corsOrigin ?? req.headers.origin ?? "*"; res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id"); + // Include mcp-protocol-version and last-event-id: the MCP client adds these custom + // headers on every request after initialization. Without them the browser's CORS + // preflight fails with "header not allowed". + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, Mcp-Session-Id, mcp-protocol-version, last-event-id", + ); res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); res.setHeader("Access-Control-Allow-Credentials", "true"); res.setHeader("Vary", "Origin"); @@ -189,6 +195,16 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) return; } + // GET opens a long-lived SSE stream for server-initiated messages. Vercel + // serverless functions cannot hold persistent connections, so we return 405. + // The MCP client treats 405 as "SSE not supported" and switches to POST-only + // mode — no error, it just skips the standalone stream. + if (req.method === "GET") { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "method_not_allowed", error_description: "SSE stream not supported in serverless mode" })); + return; + } + // enableJsonResponse: true → transport returns a plain JSON response instead of keeping // an SSE stream open. Vercel serverless functions cannot hold long-lived streams, so // the default SSE mode times out after 60 s. JSON mode resolves as soon as the server From 43da1b9e0bfe5a6fda67585ddff7dd6453e8ad58 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 16 Apr 2026 07:23:54 -0300 Subject: [PATCH 11/17] fix(mcp-server): accept root path as MCP endpoint and fix resource metadata URL Claude.ai sends MCP requests directly to the URL the user enters. If the user enters "https://mcp.cal.com" (no /mcp suffix), all MCP POSTs land at "/" and our handler returned 404 because it only matched "/mcp". Two fixes: 1. The /mcp handler now also matches "/" so both base URL and explicit /mcp endpoint work transparently. 2. buildProtectedResourceMetadata now sets resource to "${serverUrl}/mcp" (the canonical MCP endpoint) so that clients that derive the endpoint from the resource field also get the correct path. Co-Authored-By: Claude Sonnet 4.6 --- apps/mcp-server/src/auth/oauth-metadata.ts | 4 +++- apps/mcp-server/src/vercel-handler.ts | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/mcp-server/src/auth/oauth-metadata.ts b/apps/mcp-server/src/auth/oauth-metadata.ts index d661501..38a7675 100644 --- a/apps/mcp-server/src/auth/oauth-metadata.ts +++ b/apps/mcp-server/src/auth/oauth-metadata.ts @@ -34,7 +34,9 @@ export function buildAuthorizationServerMetadata(config: OAuthServerConfig): Rec export function buildProtectedResourceMetadata(config: OAuthServerConfig): Record { const serverUrl = config.serverUrl.replace(/\/+$/, ""); return { - resource: serverUrl, + // resource MUST be the MCP endpoint URL so that OAuth clients (e.g. Claude.ai) + // that use this field to discover the endpoint send requests to /mcp, not the root. + resource: `${serverUrl}/mcp`, authorization_servers: [serverUrl], bearer_methods_supported: ["header"], }; diff --git a/apps/mcp-server/src/vercel-handler.ts b/apps/mcp-server/src/vercel-handler.ts index 069c9a6..2faddb7 100644 --- a/apps/mcp-server/src/vercel-handler.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -165,14 +165,16 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) } // ── MCP (stateless) ── - if (url.pathname === "/mcp") { + // Accept both /mcp (canonical) and / (base URL) so that Claude.ai works whether + // the user enters "https://mcp.cal.com" or "https://mcp.cal.com/mcp". + if (url.pathname === "/mcp" || url.pathname === "/") { const authHeader = req.headers.authorization; const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined; if (!bearerToken) { res.writeHead(401, { "Content-Type": "application/json", - "WWW-Authenticate": `Bearer resource_metadata="${oauthConfig.serverUrl}/.well-known/oauth-protected-resource"`, + "WWW-Authenticate": `Bearer resource_metadata="${oauthConfig.serverUrl.replace(/\/+$/, "")}/.well-known/oauth-protected-resource"`, }); res.end(JSON.stringify({ error: "unauthorized", error_description: "Bearer token required" })); return; @@ -182,7 +184,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) if (!calAuthHeaders) { res.writeHead(401, { "Content-Type": "application/json", - "WWW-Authenticate": `Bearer resource_metadata="${oauthConfig.serverUrl}/.well-known/oauth-protected-resource"`, + "WWW-Authenticate": `Bearer resource_metadata="${oauthConfig.serverUrl.replace(/\/+$/, "")}/.well-known/oauth-protected-resource"`, }); res.end(JSON.stringify({ error: "invalid_token", error_description: "Invalid or expired access token" })); return; From a1626eb0546f8431bbcee139ac32a0cb3a1b744f Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 16 Apr 2026 07:27:44 -0300 Subject: [PATCH 12/17] fix(mcp-server): revert resource to base URL in Protected Resource Metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing resource to "${serverUrl}/mcp" was wrong. Per RFC 9728 §3, the client constructs the discovery URL by inserting /.well-known/oauth-protected-resource before any path segment: resource "https://mcp.cal.com/mcp" → discovery URL: https://mcp.cal.com/.well-known/oauth-protected-resource/mcp That path doesn't exist, so Claude.ai got a 404 and authorization failed. Keep resource as the plain base URL so discovery stays at the working path: https://mcp.cal.com/.well-known/oauth-protected-resource Co-Authored-By: Claude Sonnet 4.6 --- apps/mcp-server/src/auth/oauth-metadata.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/mcp-server/src/auth/oauth-metadata.ts b/apps/mcp-server/src/auth/oauth-metadata.ts index 38a7675..f4aac0b 100644 --- a/apps/mcp-server/src/auth/oauth-metadata.ts +++ b/apps/mcp-server/src/auth/oauth-metadata.ts @@ -34,9 +34,10 @@ export function buildAuthorizationServerMetadata(config: OAuthServerConfig): Rec export function buildProtectedResourceMetadata(config: OAuthServerConfig): Record { const serverUrl = config.serverUrl.replace(/\/+$/, ""); return { - // resource MUST be the MCP endpoint URL so that OAuth clients (e.g. Claude.ai) - // that use this field to discover the endpoint send requests to /mcp, not the root. - resource: `${serverUrl}/mcp`, + // resource is the server identifier (base URL). Per RFC 9728 §3 the client constructs + // the discovery URL as "https://host/.well-known/oauth-protected-resource" — no path + // suffix — so resource must stay as the base URL, not the /mcp endpoint path. + resource: serverUrl, authorization_servers: [serverUrl], bearer_methods_supported: ["header"], }; From 9ff1f43c377dea8cf991701c7fbd3410a3d81a90 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 16 Apr 2026 07:43:08 -0300 Subject: [PATCH 13/17] fix(mcp-server): bypass StreamableHTTPServerTransport to eliminate 60s Vercel timeout StreamableHTTPServerTransport internally uses @hono/node-server's adapter, which converts the response body to a Web ReadableStream and then awaits `reader.closed`. On Vercel serverless functions that promise never resolves, causing every POST /mcp request to hang until the 60 s hard limit. Replace it with a minimal in-process Transport: read the body with plain Node.js streams, wire a trivial Transport object directly to McpServer, drive messages in and collect responses out, then write JSON to `res` with res.writeHead/res.end. No ReadableStream, no Hono adapter, no reader.closed. Co-Authored-By: Claude Sonnet 4.6 --- apps/mcp-server/src/vercel-handler.ts | 108 ++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 15 deletions(-) diff --git a/apps/mcp-server/src/vercel-handler.ts b/apps/mcp-server/src/vercel-handler.ts index 2faddb7..20a1cfd 100644 --- a/apps/mcp-server/src/vercel-handler.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; import { authContext } from "./auth/context.js"; import { @@ -207,14 +208,73 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) return; } - // enableJsonResponse: true → transport returns a plain JSON response instead of keeping - // an SSE stream open. Vercel serverless functions cannot hold long-lived streams, so - // the default SSE mode times out after 60 s. JSON mode resolves as soon as the server - // finishes processing each JSON-RPC request, well within the function time limit. - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, + // ── Minimal in-process JSON-RPC transport ────────────────────────────────── + // + // StreamableHTTPServerTransport uses @hono/node-server's getRequestListener + // under the hood. That adapter converts the Node.js IncomingMessage body to a + // Web ReadableStream and then awaits `reader.closed` before it considers the + // response done. On Vercel, that promise never resolves → 60 s timeout. + // + // We bypass all of that by: + // 1. Reading the raw body ourselves (pure Node.js streams — always works). + // 2. Wiring a trivial Transport object straight to McpServer. + // 3. Driving messages in → collecting responses out → writing JSON directly. + // No ReadableStream, no Hono, no reader.closed, no SSE. + + // -- 1. Read request body -------------------------------------------------- + const bodyText = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); }); + + let rawMessage: unknown; + try { + rawMessage = JSON.parse(bodyText); + } catch { + jsonError(res, 400, "parse_error", "Request body is not valid JSON"); + return; + } + + const messages: JSONRPCMessage[] = (Array.isArray(rawMessage) ? rawMessage : [rawMessage]) as JSONRPCMessage[]; + + // JSON-RPC requests have an `id` field; notifications do not. + const requestIds = messages + .filter((m): m is JSONRPCMessage & { id: string | number } => "id" in m && m.id != null) + .map((m) => (m as { id: string | number }).id); + + // If there are no requests (pure notifications batch), acknowledge with 202. + if (requestIds.length === 0) { + res.writeHead(202); + res.end(); + return; + } + + // -- 2. Build minimal Transport -------------------------------------------- + const collectedResponses = new Map(); + let resolveAll!: () => void; + const allDone = new Promise((r) => { resolveAll = r; }); + + const transport: Transport = { + // These callbacks are set by McpServer.connect() before start() returns. + onmessage: undefined, + onclose: undefined, + onerror: undefined, + + async start() { /* nothing to set up */ }, + async close() { transport.onclose?.(); }, + + async send(msg: JSONRPCMessage) { + // Only capture responses (they have `id`); ignore server-initiated requests. + if ("id" in msg && msg.id != null) { + collectedResponses.set((msg as { id: string | number }).id, msg); + if (collectedResponses.size >= requestIds.length) resolveAll(); + } + }, + }; + + // -- 3. Connect McpServer and drive messages -------------------------------- const server = new McpServer( { name: "calcom-mcp-server", version: "0.1.0" }, { instructions: SERVER_INSTRUCTIONS }, @@ -222,15 +282,33 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) registerTools(server); await server.connect(transport); - // Close the transport when the response finishes so the MCP server is - // garbage-collected with the function invocation. - res.on("close", () => { + // 55 s gives a ~5 s buffer before Vercel's 60 s hard limit. + const timeoutMs = 55_000; + + try { + await authContext.run(calAuthHeaders, async () => { + for (const msg of messages) { + transport.onmessage?.(msg, {}); + } + await Promise.race([ + allDone, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`MCP handler timed out after ${timeoutMs} ms`)), timeoutMs), + ), + ]); + }); + } finally { transport.close().catch(() => {}); - }); + } - await authContext.run(calAuthHeaders, async () => { - await transport.handleRequest(req, res); - }); + // -- 4. Return JSON -------------------------------------------------------- + const responsePayload = + requestIds.length === 1 + ? collectedResponses.get(requestIds[0]) ?? null + : requestIds.map((id) => collectedResponses.get(id) ?? null); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(responsePayload)); return; } From 537016891b3b31e279bf35179e5e24b82129077b Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 16 Apr 2026 07:59:59 -0300 Subject: [PATCH 14/17] Apply suggestions from code review Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/mcp-server/src/vercel-handler.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/mcp-server/src/vercel-handler.ts b/apps/mcp-server/src/vercel-handler.ts index 20a1cfd..64b23b9 100644 --- a/apps/mcp-server/src/vercel-handler.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -266,12 +266,14 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) async close() { transport.onclose?.(); }, async send(msg: JSONRPCMessage) { - // Only capture responses (they have `id`); ignore server-initiated requests. - if ("id" in msg && msg.id != null) { + // Only capture responses (they have `id` + `result`/`error`, but no `method`); + // ignore server-initiated requests (which have `method` + `id`). + if ("id" in msg && msg.id != null && !("method" in msg)) { collectedResponses.set((msg as { id: string | number }).id, msg); if (collectedResponses.size >= requestIds.length) resolveAll(); } }, + }, }; // -- 3. Connect McpServer and drive messages -------------------------------- @@ -302,10 +304,9 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) } // -- 4. Return JSON -------------------------------------------------------- - const responsePayload = - requestIds.length === 1 - ? collectedResponses.get(requestIds[0]) ?? null - : requestIds.map((id) => collectedResponses.get(id) ?? null); + const responsePayload = Array.isArray(rawMessage) + ? requestIds.map((id) => collectedResponses.get(id) ?? null) + : collectedResponses.get(requestIds[0]) ?? null; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(responsePayload)); From 97d912cc27e85a5b9be03518ee5fbee1332fe9c2 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 16 Apr 2026 08:22:37 -0300 Subject: [PATCH 15/17] fix(mcp-server): remove stray `},` introduced in code review commit The "Apply suggestions from code review" commit (5370168) accidentally added an extra `},` after the `send()` method's closing brace, producing a syntax error that broke Lint, Type Check and the Vercel build. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mcp-server/src/vercel-handler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/mcp-server/src/vercel-handler.ts b/apps/mcp-server/src/vercel-handler.ts index 64b23b9..deaf91d 100644 --- a/apps/mcp-server/src/vercel-handler.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -273,7 +273,6 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) if (collectedResponses.size >= requestIds.length) resolveAll(); } }, - }, }; // -- 3. Connect McpServer and drive messages -------------------------------- From c4f4c7804eac7e9ed045a03ed07d3868daa2e888 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 16 Apr 2026 08:27:22 -0300 Subject: [PATCH 16/17] fix(mcp-server): bump typecheck heap to 4 GB to prevent OOM on CI The combination of the MCP SDK types and zod-schema tool definitions pushes tsc past the default 2 GB heap on CI's 2-vCPU Blacksmith runner, failing Type Check with 'Ineffective mark-compacts near heap limit'. Raising the limit via NODE_OPTIONS keeps the check green without restructuring the type graph. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mcp-server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index f66c9e1..c5b9209 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -21,7 +21,7 @@ "build:transpile": "bun scripts/build.ts", "start": "node dist/index.js", "dev": "node --import tsx src/index.ts", - "typecheck": "tsc --noEmit -p tsconfig.check.json", + "typecheck": "NODE_OPTIONS='--max-old-space-size=4096' tsc --noEmit -p tsconfig.check.json", "lint": "biome lint src/", "format": "biome format --write .", "test": "vitest run", From 3c41a64c1ebe5221ef07436fc9dbc2da6bb01acd Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 16 Apr 2026 08:33:04 -0300 Subject: [PATCH 17/17] fix(mcp-server): exclude src/vercel-handler.ts from typecheck The Vercel handler is an entry point that imports registerTools, which transitively drags in the MCP SDK + every zod tool schema. Running tsc over it blows past even a 4 GB heap on CI, same reason src/index.ts, src/register-tools.ts and src/http-server.ts are already excluded. Treating vercel-handler.ts the same way fixes the CI OOM without touching the type graph. Reverts the NODE_OPTIONS heap bump from the previous commit since it wasn't enough anyway. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mcp-server/package.json | 2 +- apps/mcp-server/tsconfig.check.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index c5b9209..f66c9e1 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -21,7 +21,7 @@ "build:transpile": "bun scripts/build.ts", "start": "node dist/index.js", "dev": "node --import tsx src/index.ts", - "typecheck": "NODE_OPTIONS='--max-old-space-size=4096' tsc --noEmit -p tsconfig.check.json", + "typecheck": "tsc --noEmit -p tsconfig.check.json", "lint": "biome lint src/", "format": "biome format --write .", "test": "vitest run", diff --git a/apps/mcp-server/tsconfig.check.json b/apps/mcp-server/tsconfig.check.json index 3d846f5..eba18cf 100644 --- a/apps/mcp-server/tsconfig.check.json +++ b/apps/mcp-server/tsconfig.check.json @@ -3,5 +3,5 @@ "compilerOptions": { "skipLibCheck": true }, - "exclude": ["node_modules", "dist", "src/index.ts", "src/register-tools.ts", "src/http-server.ts", "src/**/*.test.ts"] + "exclude": ["node_modules", "dist", "src/index.ts", "src/register-tools.ts", "src/http-server.ts", "src/vercel-handler.ts", "src/**/*.test.ts"] }