diff --git a/apps/mcp-server/.env.example b/apps/mcp-server/.env.example index 1ecfcb6e..0394636d 100644 --- a/apps/mcp-server/.env.example +++ b/apps/mcp-server/.env.example @@ -41,7 +41,14 @@ CAL_API_KEY=cal_live_xxxx # Comma-separated allowlist of hostnames permitted as non-loopback https redirect # URIs at dynamic client registration. Loopback (localhost / 127.0.0.0/8 / ::1) is -# always allowed; cleartext http to non-loopback hosts is always rejected. When -# unset, any https host is accepted (open DCR) but logged. Set this in production -# to the hosts of trusted MCP clients, e.g. claude.ai,chatgpt.com +# always allowed; cleartext http to non-loopback hosts is always rejected. When unset, +# external https hosts are rejected. Set this in production to the hosts of trusted +# MCP clients, e.g. claude.ai,chatgpt.com # ALLOWED_REDIRECT_HOSTS=claude.ai,chatgpt.com + +# Unsafe/dev-only escape hatch: permits any external https redirect URI when no +# allowlist is configured. Do not set this in production. +# ALLOW_OPEN_REDIRECT_REGISTRATION=false + +# Browser CORS origin for remote HTTP clients. Defaults to MCP_SERVER_URL origin. +# CORS_ORIGIN=https://mcp.example.com diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 263d9aaf..c84411de 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -4,10 +4,10 @@ A **Model Context Protocol (MCP)** server that wraps the [Cal.com Platform API v ## Features -- **55 tools** covering Bookings, Event Types, Schedules, Availability, Calendars, Conferencing, Booking Routing Trace, Routing Forms, Organizations, Teams, and User Profile (each with MCP tool annotations: `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) +- **56 tools** covering Bookings, Event Types, Schedules, Availability, Calendars, Conferencing, Booking Routing Trace, Routing Forms, Organizations, Teams, and User Profile (each with MCP tool annotations: `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) - **Dual transport** — stdio for local dev tooling, StreamableHTTP for remote/production - **Dual auth** — API key for stdio (local dev), OAuth 2.1 Authorization Code + PKCE for HTTP (production) -- **Per-user token storage** — encrypted at rest with AES-256-GCM in SQLite +- **Per-user token storage** — encrypted at rest with AES-256-GCM in Postgres - **Structured error handling** with clean MCP error responses - **Zod-validated inputs** for every tool @@ -58,10 +58,12 @@ cp apps/mcp-server/.env.example apps/mcp-server/.env | `TOKEN_ENCRYPTION_KEY` | Yes | — | 64-char hex string (32 bytes) for AES-256-GCM token encryption | | `MCP_SERVER_URL` | Yes | — | Public URL of this server (e.g. `https://mcp.example.com`) | | `CAL_OAUTH_SCOPES` | No | Core scopes plus `ORG_BOOKING_READ TEAM_BOOKING_READ ORG_MEMBERSHIP_READ ORG_MEMBERSHIP_WRITE ORG_ROUTING_FORM_READ ORG_ATTRIBUTES_READ ORG_ATTRIBUTES_WRITE` | Space-separated Cal.com OAuth scopes requested during authorization | -| `DATABASE_PATH` | No | `mcp-server.db` | SQLite database file path | +| `DATABASE_URL` | Yes | — | Postgres connection string for HTTP OAuth state and token storage | | `RATE_LIMIT_WINDOW_MS` | No | `60000` | Rate limit window in ms (per IP) | | `RATE_LIMIT_MAX` | No | `30` | Max OAuth requests per window per IP | -| `ALLOWED_REDIRECT_HOSTS` | No | — | Comma-separated allowlist of hostnames for non-loopback `https` redirect URIs at client registration. Loopback is always allowed; non-loopback cleartext `http` is always rejected. When unset, any `https` host is accepted (open DCR) but logged. | +| `ALLOWED_REDIRECT_HOSTS` | No | — | Comma-separated allowlist of hostnames for non-loopback `https` redirect URIs at client registration. Loopback is always allowed; non-loopback cleartext `http` is always rejected. When unset, external hosts are rejected. | +| `ALLOW_OPEN_REDIRECT_REGISTRATION` | No | `false` | Unsafe/dev-only escape hatch that allows any external `https` redirect URI when no allowlist is configured. Do not set in production. | +| `CORS_ORIGIN` | No | `MCP_SERVER_URL` origin | Browser CORS origin for remote HTTP clients. Set this when the browser client origin differs from the MCP server origin. | | `OPENAI_APPS_CHALLENGE_TOKEN` | No | — | Token served at `/.well-known/openai-apps-challenge` for OpenAI Apps domain verification. When unset, the endpoint returns 404. | ## Transport Modes @@ -166,12 +168,12 @@ The HTTP server implements a full OAuth 2.1 Authorization Server. MCP clients co 2. **Client starts auth** → `GET /oauth/authorize` with `client_id`, `redirect_uri`, `code_challenge` (S256), `state` 3. **Server redirects to Cal.com** → user authorizes on Cal.com 4. **Cal.com redirects back** → `GET /oauth/callback` with `code` + `state` -5. **Server exchanges code** → calls Cal.com token endpoint, stores encrypted Cal.com tokens in SQLite +5. **Server exchanges code** → calls Cal.com token endpoint, stores encrypted Cal.com tokens in Postgres 6. **Server redirects to client** → with an authorization `code` + original `state` 7. **Client exchanges code** → `POST /oauth/token` with `code`, `code_verifier`, `redirect_uri` → receives `access_token` + `refresh_token` 8. **Client uses token** → `POST /mcp` with `Authorization: Bearer ` -The server acts as an intermediary: it issues its own access tokens to MCP clients, and each token maps to encrypted Cal.com credentials stored in SQLite. When Cal.com tokens expire, the server auto-refreshes them transparently. +The server acts as an intermediary: it issues its own access tokens to MCP clients, and each token maps to encrypted Cal.com credentials stored in Postgres. When Cal.com tokens expire, the server auto-refreshes them transparently. **Security:** - All Cal.com tokens are encrypted at rest with AES-256-GCM (via `TOKEN_ENCRYPTION_KEY`) @@ -179,9 +181,9 @@ The server acts as an intermediary: it issues its own access tokens to MCP clien - Auth codes are single-use - Expired tokens are cleaned up automatically every 5 minutes - In-process rate limiting on all OAuth endpoints (token bucket per IP, configurable via `RATE_LIMIT_WINDOW_MS` / `RATE_LIMIT_MAX`) -- Redirect URIs registered via dynamic client registration are constrained: loopback (`localhost` / `127.0.0.0/8` / `::1`) is always allowed, cleartext `http` to non-loopback hosts is always rejected, and non-loopback `https` hosts can be restricted to a vetted allowlist via `ALLOWED_REDIRECT_HOSTS` (recommended in production to limit the open-DCR phishing surface) +- Redirect URIs registered via dynamic client registration are constrained: loopback (`localhost` / `127.0.0.0/8` / `::1`) is always allowed, cleartext `http` to non-loopback hosts is always rejected, and non-loopback `https` hosts require `ALLOWED_REDIRECT_HOSTS` unless `ALLOW_OPEN_REDIRECT_REGISTRATION=true` is explicitly set for unsafe/dev use -## Tools (55) +## Tools (56) Each tool exposes MCP [tool annotations](https://modelcontextprotocol.io/specification/draft/server/tools#tool-annotations) — a human-readable `title` plus behaviour hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) so MCP clients can render them appropriately and apply safety policies. diff --git a/apps/mcp-server/src/auth/oauth-handlers.test.ts b/apps/mcp-server/src/auth/oauth-handlers.test.ts index 6f9c564b..7fae5f30 100644 --- a/apps/mcp-server/src/auth/oauth-handlers.test.ts +++ b/apps/mcp-server/src/auth/oauth-handlers.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; // oauth-handlers transitively imports the token store, which creates a Postgres // pool at import time. Mock it so these pure-function tests need no database. @@ -11,7 +11,11 @@ vi.mock("@vercel/postgres", () => { const { isLoopbackHost, validateRedirectUri } = await import("./oauth-handlers.js"); type RedirectUriPolicy = import("./oauth-handlers.js").RedirectUriPolicy; -const OPEN: RedirectUriPolicy = { allowedHosts: [] }; +const CLOSED: RedirectUriPolicy = { + allowedHosts: [], + allowExternalRedirectsWithoutAllowlist: false, +}; +const OPEN: RedirectUriPolicy = { allowedHosts: [], allowExternalRedirectsWithoutAllowlist: true }; const ALLOWLIST: RedirectUriPolicy = { allowedHosts: ["claude.ai", "chatgpt.com"] }; describe("isLoopbackHost", () => { @@ -36,27 +40,33 @@ describe("isLoopbackHost", () => { describe("validateRedirectUri", () => { it("allows loopback over http (desktop clients)", () => { - expect(validateRedirectUri("http://localhost:8765/callback", OPEN).ok).toBe(true); - expect(validateRedirectUri("http://127.0.0.5:9000/cb", OPEN).ok).toBe(true); - expect(validateRedirectUri("http://[::1]:9000/cb", OPEN).ok).toBe(true); + expect(validateRedirectUri("http://localhost:8765/callback", CLOSED).ok).toBe(true); + expect(validateRedirectUri("http://127.0.0.5:9000/cb", CLOSED).ok).toBe(true); + expect(validateRedirectUri("http://[::1]:9000/cb", CLOSED).ok).toBe(true); }); it("rejects cleartext http to non-loopback hosts", () => { - const res = validateRedirectUri("http://evil.com/callback", OPEN); + const res = validateRedirectUri("http://evil.com/callback", CLOSED); expect(res.ok).toBe(false); if (!res.ok) expect(res.reason).toContain("https"); }); it("rejects non-http(s) schemes", () => { - expect(validateRedirectUri("ftp://example.com/cb", OPEN).ok).toBe(false); - expect(validateRedirectUri("javascript:alert(1)", OPEN).ok).toBe(false); + expect(validateRedirectUri("ftp://example.com/cb", CLOSED).ok).toBe(false); + expect(validateRedirectUri("javascript:alert(1)", CLOSED).ok).toBe(false); }); it("rejects malformed URLs", () => { - expect(validateRedirectUri("not a url", OPEN).ok).toBe(false); + expect(validateRedirectUri("not a url", CLOSED).ok).toBe(false); }); - it("with no allowlist, accepts any https host (open DCR default)", () => { + it("with no allowlist, rejects external https hosts by default", () => { + const res = validateRedirectUri("https://evil.com/callback", CLOSED); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.reason).toContain("ALLOWED_REDIRECT_HOSTS"); + }); + + it("with the explicit open-DCR override, accepts any https host", () => { expect(validateRedirectUri("https://evil.com/callback", OPEN).ok).toBe(true); }); diff --git a/apps/mcp-server/src/auth/oauth-handlers.ts b/apps/mcp-server/src/auth/oauth-handlers.ts index d61a27cc..da5dfefe 100644 --- a/apps/mcp-server/src/auth/oauth-handlers.ts +++ b/apps/mcp-server/src/auth/oauth-handlers.ts @@ -1,22 +1,27 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { CAL_API_VERSION } from "../config.js"; -import { logger } from "../utils/logger.js"; -import { generateCodeVerifier, generateCodeChallenge, generateState, verifyCodeChallenge } from "./pkce.js"; import { - createRegisteredClient, - getRegisteredClient, - createPendingAuth, - getPendingAuth, - deletePendingAuth, - createAuthCode, consumeAuthCode, createAccessToken, - rotateAccessToken, + createAuthCode, + createPendingAuth, + createRegisteredClient, deleteAccessToken, deleteAccessTokenByRefresh, + deletePendingAuth, getAccessToken, + getPendingAuth, + getRegisteredClient, + rotateAccessToken, updateCalTokens, } from "../storage/token-store.js"; +import { logger } from "../utils/logger.js"; +import { + generateCodeChallenge, + generateCodeVerifier, + generateState, + verifyCodeChallenge, +} from "./pkce.js"; export interface OAuthConfig { /** Public URL of the MCP server */ @@ -39,10 +44,12 @@ const MAX_BODY_SIZE = 1024 * 1024; // 1 MB export interface RedirectUriPolicy { /** * Lowercased exact hostnames permitted for non-loopback `https` redirect URIs. - * When empty, any `https` host is accepted (loopback is always accepted, and - * cleartext `http` to non-loopback hosts is always rejected). + * When empty, non-loopback external hosts are rejected unless the explicit + * open-DCR override is enabled. Loopback is always accepted, and cleartext + * `http` to non-loopback hosts is always rejected. */ allowedHosts: string[]; + allowExternalRedirectsWithoutAllowlist?: boolean; } /** @@ -68,7 +75,7 @@ export function isLoopbackHost(hostname: string): boolean { */ export function validateRedirectUri( uri: string, - policy: RedirectUriPolicy, + policy: RedirectUriPolicy ): { ok: true } | { ok: false; reason: string } { let parsed: URL; try { @@ -77,7 +84,10 @@ export function validateRedirectUri( return { ok: false, reason: "not a valid URL" }; } if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - return { ok: false, reason: `invalid scheme: ${parsed.protocol} — only http and https are allowed` }; + return { + ok: false, + reason: `invalid scheme: ${parsed.protocol} — only http and https are allowed`, + }; } if (isLoopbackHost(parsed.hostname)) { return { ok: true }; @@ -85,8 +95,21 @@ export function validateRedirectUri( if (parsed.protocol !== "https:") { return { ok: false, reason: "non-loopback redirect_uri must use https" }; } - if (policy.allowedHosts.length > 0 && !policy.allowedHosts.includes(parsed.hostname.toLowerCase())) { - return { ok: false, reason: `redirect_uri host '${parsed.hostname.toLowerCase()}' is not in the allowed list` }; + if (policy.allowedHosts.length === 0 && !policy.allowExternalRedirectsWithoutAllowlist) { + return { + ok: false, + reason: + "external redirect_uri hosts require ALLOWED_REDIRECT_HOSTS or ALLOW_OPEN_REDIRECT_REGISTRATION=true", + }; + } + if ( + policy.allowedHosts.length > 0 && + !policy.allowedHosts.includes(parsed.hostname.toLowerCase()) + ) { + return { + ok: false, + reason: `redirect_uri host '${parsed.hostname.toLowerCase()}' is not in the allowed list`, + }; } return { ok: true }; } @@ -121,7 +144,7 @@ function jsonResponse(res: ServerResponse, status: number, body: unknown): void export async function handleRegister( req: IncomingMessage, res: ServerResponse, - policy: RedirectUriPolicy, + policy: RedirectUriPolicy ): Promise { if (req.method !== "POST") { jsonResponse(res, 405, { error: "method_not_allowed" }); @@ -148,24 +171,33 @@ export async function handleRegister( const redirectUris: string[] = []; for (const uri of body.redirect_uris) { if (typeof uri !== "string") { - jsonResponse(res, 400, { error: "invalid_request", error_description: "Each redirect_uri must be a string" }); + jsonResponse(res, 400, { + error: "invalid_request", + error_description: "Each redirect_uri must be a string", + }); return; } const check = validateRedirectUri(uri, policy); if (!check.ok) { - jsonResponse(res, 400, { error: "invalid_request", error_description: `Invalid redirect_uri: ${check.reason}` }); + jsonResponse(res, 400, { + error: "invalid_request", + error_description: `Invalid redirect_uri: ${check.reason}`, + }); return; } redirectUris.push(uri); } - // Surface external (non-loopback) registrations: with no allowlist configured these - // are still accepted, but they are the surface a confused-deputy phishing attack uses. - if (policy.allowedHosts.length === 0) { + // Surface explicitly open external registrations: this is the surface a + // confused-deputy phishing attack uses, so production should prefer an allowlist. + if (policy.allowedHosts.length === 0 && policy.allowExternalRedirectsWithoutAllowlist) { for (const uri of redirectUris) { const host = new URL(uri).hostname; if (!isLoopbackHost(host)) { - logger.warn("Registered client with non-loopback redirect_uri and no allowlist configured", { host }); + logger.warn( + "Registered client with non-loopback redirect_uri and no allowlist configured", + { host } + ); } } } @@ -186,7 +218,7 @@ export async function handleRegister( export async function handleAuthorize( req: IncomingMessage, res: ServerResponse, - config: OAuthConfig, + config: OAuthConfig ): Promise { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); @@ -204,12 +236,16 @@ export async function handleAuthorize( if (!clientId || !redirectUri || !codeChallenge || !clientState) { jsonResponse(res, 400, { error: "invalid_request", - error_description: "Missing required parameters: client_id, redirect_uri, code_challenge, state", + error_description: + "Missing required parameters: client_id, redirect_uri, code_challenge, state", }); return; } if (codeChallengeMethod && codeChallengeMethod !== "S256") { - jsonResponse(res, 400, { error: "invalid_request", error_description: "Only S256 code_challenge_method is supported" }); + jsonResponse(res, 400, { + error: "invalid_request", + error_description: "Only S256 code_challenge_method is supported", + }); return; } @@ -219,7 +255,10 @@ export async function handleAuthorize( return; } if (!client.redirectUris.includes(redirectUri)) { - jsonResponse(res, 400, { error: "invalid_request", error_description: "redirect_uri not registered for this client" }); + jsonResponse(res, 400, { + error: "invalid_request", + error_description: "redirect_uri not registered for this client", + }); return; } @@ -265,7 +304,7 @@ export async function handleAuthorize( export async function handleCallback( req: IncomingMessage, res: ServerResponse, - config: OAuthConfig, + config: OAuthConfig ): Promise { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); @@ -282,13 +321,19 @@ export async function handleCallback( } if (!code || !state) { - jsonResponse(res, 400, { error: "invalid_request", error_description: "Missing code or state" }); + jsonResponse(res, 400, { + error: "invalid_request", + error_description: "Missing code or state", + }); return; } const pending = await getPendingAuth(state); if (!pending) { - jsonResponse(res, 400, { error: "invalid_request", error_description: "Unknown or expired state parameter" }); + jsonResponse(res, 400, { + error: "invalid_request", + error_description: "Unknown or expired state parameter", + }); return; } @@ -318,7 +363,10 @@ export async function handleCallback( // Do not log response body — it may contain sensitive Cal.com error details logger.error("Cal.com token exchange failed", { status: exchangeRes.status }); await deletePendingAuth(state); - jsonResponse(res, 502, { error: "server_error", error_description: "Token exchange with Cal.com failed" }); + jsonResponse(res, 502, { + error: "server_error", + error_description: "Token exchange with Cal.com failed", + }); return; } @@ -355,10 +403,7 @@ export async function handleCallback( // ── Token Exchange (POST /oauth/token) ── -export async function handleToken( - req: IncomingMessage, - res: ServerResponse, -): Promise { +export async function handleToken(req: IncomingMessage, res: ServerResponse): Promise { if (req.method !== "POST") { jsonResponse(res, 405, { error: "method_not_allowed" }); return; @@ -379,7 +424,10 @@ export async function handleToken( jsonResponse(res, 400, { error: "unsupported_grant_type" }); } -async function handleAuthorizationCodeGrant(params: URLSearchParams, res: ServerResponse): Promise { +async function handleAuthorizationCodeGrant( + params: URLSearchParams, + res: ServerResponse +): Promise { const code = params.get("code"); const redirectUri = params.get("redirect_uri"); const codeVerifier = params.get("code_verifier"); @@ -388,24 +436,34 @@ async function handleAuthorizationCodeGrant(params: URLSearchParams, res: Server if (!code || !redirectUri || !codeVerifier || !clientId) { jsonResponse(res, 400, { error: "invalid_request", - error_description: "Missing required parameters: code, redirect_uri, code_verifier, client_id", + error_description: + "Missing required parameters: code, redirect_uri, code_verifier, client_id", }); return; } const authCode = await consumeAuthCode(code); if (!authCode) { - jsonResponse(res, 400, { error: "invalid_grant", error_description: "Invalid, expired, or already-used authorization code" }); + jsonResponse(res, 400, { + error: "invalid_grant", + error_description: "Invalid, expired, or already-used authorization code", + }); return; } if (authCode.clientId !== clientId || authCode.redirectUri !== redirectUri) { - jsonResponse(res, 400, { error: "invalid_grant", error_description: "client_id or redirect_uri mismatch" }); + jsonResponse(res, 400, { + error: "invalid_grant", + error_description: "client_id or redirect_uri mismatch", + }); return; } if (!verifyCodeChallenge(codeVerifier, authCode.codeChallenge)) { - jsonResponse(res, 400, { error: "invalid_grant", error_description: "PKCE code_verifier verification failed" }); + jsonResponse(res, 400, { + error: "invalid_grant", + error_description: "PKCE code_verifier verification failed", + }); return; } @@ -424,10 +482,16 @@ async function handleAuthorizationCodeGrant(params: URLSearchParams, res: Server }); } -async function handleRefreshTokenGrant(params: URLSearchParams, res: ServerResponse): Promise { +async function handleRefreshTokenGrant( + params: URLSearchParams, + res: ServerResponse +): Promise { const refreshToken = params.get("refresh_token"); if (!refreshToken) { - jsonResponse(res, 400, { error: "invalid_request", error_description: "Missing refresh_token" }); + jsonResponse(res, 400, { + error: "invalid_request", + error_description: "Missing refresh_token", + }); return; } @@ -447,10 +511,7 @@ async function handleRefreshTokenGrant(params: URLSearchParams, res: ServerRespo // ── Token Revocation (POST /oauth/revoke) ── -export async function handleRevoke( - req: IncomingMessage, - res: ServerResponse, -): Promise { +export async function handleRevoke(req: IncomingMessage, res: ServerResponse): Promise { if (req.method !== "POST") { jsonResponse(res, 405, { error: "method_not_allowed" }); return; @@ -477,8 +538,10 @@ export async function handleRevoke( export async function refreshCalTokens( accessTokenValue: string, calRefreshToken: string, - config: OAuthConfig, -): Promise<{ calAccessToken: string; calRefreshToken: string; calTokenExpiresAt: number } | undefined> { + config: OAuthConfig +): Promise< + { calAccessToken: string; calRefreshToken: string; calTokenExpiresAt: number } | undefined +> { const refreshUrl = `${config.calApiBaseUrl}/v2/auth/oauth2/token`; const tokenFetchTimeoutMs = Number(process.env.TOKEN_FETCH_TIMEOUT_MS) || 10_000; @@ -511,7 +574,12 @@ export async function refreshCalTokens( const newCalRefreshToken = data.refresh_token; const newCalTokenExpiresAt = Math.floor(Date.now() / 1000) + (data.expires_in ?? 3600); - await updateCalTokens(accessTokenValue, newCalAccessToken, newCalRefreshToken, newCalTokenExpiresAt); + await updateCalTokens( + accessTokenValue, + newCalAccessToken, + newCalRefreshToken, + newCalTokenExpiresAt + ); return { calAccessToken: newCalAccessToken, @@ -527,7 +595,7 @@ export async function refreshCalTokens( */ export async function resolveCalAuthHeaders( bearerToken: string, - config: OAuthConfig, + config: OAuthConfig ): Promise | undefined> { const record = await getAccessToken(bearerToken); if (!record) return undefined; diff --git a/apps/mcp-server/src/config.ts b/apps/mcp-server/src/config.ts index b06b6243..6e33f6ea 100644 --- a/apps/mcp-server/src/config.ts +++ b/apps/mcp-server/src/config.ts @@ -68,7 +68,9 @@ const httpSchema = baseSchema.extend({ * Comma-separated allowlist of hostnames permitted for non-loopback `https` * redirect URIs at Dynamic Client Registration. Loopback redirect URIs are * always allowed; cleartext `http` to non-loopback hosts is always rejected. - * When empty, any `https` host is accepted (open DCR) but logged. + * When empty, external `https` hosts are rejected unless + * ALLOW_OPEN_REDIRECT_REGISTRATION=true is explicitly set. Loopback redirect + * URIs remain allowed for desktop/native clients. */ allowedRedirectHosts: z .string() @@ -81,6 +83,10 @@ const httpSchema = baseSchema.extend({ .filter(Boolean) : [] ), + allowOpenRedirectRegistration: z + .enum(["true", "false", "1", "0"]) + .transform((val) => val === "true" || val === "1") + .default("false"), trustProxy: z .enum(["true", "false", "1", "0"]) .transform((val) => val === "true" || val === "1") @@ -123,6 +129,7 @@ function readEnv(): Record { sessionIdleTimeoutMs: process.env.SESSION_IDLE_TIMEOUT_MS || undefined, maxRegisteredClients: process.env.MAX_REGISTERED_CLIENTS || undefined, allowedRedirectHosts: process.env.ALLOWED_REDIRECT_HOSTS || undefined, + allowOpenRedirectRegistration: process.env.ALLOW_OPEN_REDIRECT_REGISTRATION || undefined, trustProxy: process.env.TRUST_PROXY || undefined, corsOrigin: process.env.CORS_ORIGIN || undefined, fetchTimeoutMs: process.env.FETCH_TIMEOUT_MS || undefined, diff --git a/apps/mcp-server/src/http-server.ts b/apps/mcp-server/src/http-server.ts index c7db3deb..88a4228c 100644 --- a/apps/mcp-server/src/http-server.ts +++ b/apps/mcp-server/src/http-server.ts @@ -1,26 +1,27 @@ -import { createServer } from "node:http"; import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { authContext } from "./auth/context.js"; -import { SERVER_INSTRUCTIONS } from "./server-instructions.js"; -import { - buildAuthorizationServerMetadata, - buildProtectedResourceMetadata, -} from "./auth/oauth-metadata.js"; +import type { OAuthConfig } from "./auth/oauth-handlers.js"; import { - handleRegister, handleAuthorize, handleCallback, - handleToken, + handleRegister, handleRevoke, + handleToken, resolveCalAuthHeaders, } from "./auth/oauth-handlers.js"; -import type { OAuthConfig } from "./auth/oauth-handlers.js"; -import { initDb, endPool, sql } from "./storage/db.js"; +import { + buildAuthorizationServerMetadata, + buildProtectedResourceMetadata, +} from "./auth/oauth-metadata.js"; +import { SERVER_INSTRUCTIONS } from "./server-instructions.js"; +import { endPool, initDb, sql } from "./storage/db.js"; import { cleanupExpired, countRegisteredClients } from "./storage/token-store.js"; +import { applyCorsHeaders, resolveCorsOrigin } from "./utils/http-security.js"; import { logger, withLogContext } from "./utils/logger.js"; -import { RateLimiter, getClientIp, sendRateLimited } from "./utils/rate-limiter.js"; +import { getClientIp, RateLimiter, sendRateLimited } from "./utils/rate-limiter.js"; export interface HttpServerConfig { port: number; @@ -30,8 +31,9 @@ export interface HttpServerConfig { maxSessions?: number; sessionIdleTimeoutMs?: number; maxRegisteredClients?: number; - /** Allowlist of hostnames permitted for non-loopback https redirect URIs (empty = any https host). */ + /** Allowlist of hostnames permitted for non-loopback https redirect URIs. */ allowedRedirectHosts?: string[]; + allowOpenRedirectRegistration?: boolean; corsOrigin?: string; shutdownTimeoutMs?: number; /** @@ -66,31 +68,45 @@ const startedAt = Date.now(); export async function startHttpServer( registerTools: (server: McpServer) => void, - config: HttpServerConfig, + config: HttpServerConfig ): Promise { const { port, oauthConfig } = config; const maxSessions = config.maxSessions ?? (Number(process.env.MAX_SESSIONS) || 10_000); - const sessionIdleTimeoutMs = config.sessionIdleTimeoutMs ?? (Number(process.env.SESSION_IDLE_TIMEOUT_MS) || 30 * 60 * 1000); - const maxRegisteredClients = config.maxRegisteredClients ?? (Number(process.env.MAX_REGISTERED_CLIENTS) || 10_000); - const redirectUriPolicy = { allowedHosts: config.allowedRedirectHosts ?? [] }; - const corsOrigin = config.corsOrigin ?? process.env.CORS_ORIGIN; - const shutdownTimeoutMs = config.shutdownTimeoutMs ?? (Number(process.env.SHUTDOWN_TIMEOUT_MS) || 10_000); - const openaiAppsChallengeToken = config.openaiAppsChallengeToken ?? process.env.OPENAI_APPS_CHALLENGE_TOKEN; + const sessionIdleTimeoutMs = + config.sessionIdleTimeoutMs ?? (Number(process.env.SESSION_IDLE_TIMEOUT_MS) || 30 * 60 * 1000); + const maxRegisteredClients = + config.maxRegisteredClients ?? (Number(process.env.MAX_REGISTERED_CLIENTS) || 10_000); + const redirectUriPolicy = { + allowedHosts: config.allowedRedirectHosts ?? [], + allowExternalRedirectsWithoutAllowlist: config.allowOpenRedirectRegistration ?? false, + }; + const corsOrigin = resolveCorsOrigin( + config.corsOrigin ?? process.env.CORS_ORIGIN, + oauthConfig.serverUrl + ); + const shutdownTimeoutMs = + config.shutdownTimeoutMs ?? (Number(process.env.SHUTDOWN_TIMEOUT_MS) || 10_000); + const openaiAppsChallengeToken = + config.openaiAppsChallengeToken ?? process.env.OPENAI_APPS_CHALLENGE_TOKEN; await initDb(); - const rateLimitWindowMs = config.rateLimitWindowMs ?? (Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000); + const rateLimitWindowMs = + config.rateLimitWindowMs ?? (Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000); const rateLimitMax = config.rateLimitMax ?? (Number(process.env.RATE_LIMIT_MAX) || 30); const oauthRateLimiter = new RateLimiter({ windowMs: rateLimitWindowMs, max: rateLimitMax }); oauthRateLimiter.startGc(); const mcpRateLimiter = new RateLimiter({ windowMs: rateLimitWindowMs, max: rateLimitMax * 3 }); mcpRateLimiter.startGc(); - const cleanupInterval = setInterval(() => { - cleanupExpired().catch((err) => { - logger.error("Cleanup error", { error: String(err) }); - }); - }, 5 * 60 * 1000); + const cleanupInterval = setInterval( + () => { + cleanupExpired().catch((err) => { + logger.error("Cleanup error", { error: String(err) }); + }); + }, + 5 * 60 * 1000 + ); const sessions = new Map< string, @@ -120,14 +136,7 @@ export async function startHttpServer( /** Add CORS headers if configured. */ function setCorsHeaders(res: import("node:http").ServerResponse): void { - const origin = corsOrigin ?? "*"; - 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"); - } + applyCorsHeaders(res, corsOrigin); } const httpServer = createServer(async (req, res) => { @@ -148,16 +157,20 @@ export async function startHttpServer( try { await sql`SELECT 1`; dbOk = true; - } catch { /* db not healthy */ } + } catch { + /* db not healthy */ + } const status = dbOk ? "ok" : "degraded"; const code = dbOk ? 200 : 503; res.writeHead(code, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - status, - sessions: sessions.size, - db: dbOk ? "ok" : "error", - uptime: Math.floor((Date.now() - startedAt) / 1000), - })); + res.end( + JSON.stringify({ + status, + sessions: sessions.size, + db: dbOk ? "ok" : "error", + uptime: Math.floor((Date.now() - startedAt) / 1000), + }) + ); return; } @@ -204,7 +217,12 @@ export async function startHttpServer( const currentCount = await countRegisteredClients(); if (currentCount >= maxRegisteredClients) { res.writeHead(503, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "server_error", error_description: "Maximum number of registered clients reached" })); + res.end( + JSON.stringify({ + error: "server_error", + error_description: "Maximum number of registered clients reached", + }) + ); return; } await handleRegister(req, res, redirectUriPolicy); @@ -244,7 +262,9 @@ export async function startHttpServer( "Content-Type": "application/json", "WWW-Authenticate": `Bearer resource_metadata="${oauthConfig.serverUrl}/.well-known/oauth-protected-resource"`, }); - res.end(JSON.stringify({ error: "unauthorized", error_description: "Bearer token required" })); + res.end( + JSON.stringify({ error: "unauthorized", error_description: "Bearer token required" }) + ); return; } @@ -255,7 +275,12 @@ export async function startHttpServer( "Content-Type": "application/json", "WWW-Authenticate": `Bearer resource_metadata="${oauthConfig.serverUrl}/.well-known/oauth-protected-resource"`, }); - res.end(JSON.stringify({ error: "invalid_token", error_description: "Invalid or expired access token" })); + res.end( + JSON.stringify({ + error: "invalid_token", + error_description: "Invalid or expired access token", + }) + ); return; } const sessionId = req.headers["mcp-session-id"] as string | undefined; @@ -276,13 +301,20 @@ export async function startHttpServer( const existingSession = sessionId ? sessions.get(sessionId) : undefined; if (sessionId && existingSession) { - const freshHeaders = bearerToken ? await resolveCalAuthHeaders(bearerToken, oauthConfig) : undefined; + const freshHeaders = bearerToken + ? await resolveCalAuthHeaders(bearerToken, oauthConfig) + : undefined; if (!freshHeaders) { res.writeHead(401, { "Content-Type": "application/json", "WWW-Authenticate": `Bearer resource_metadata="${oauthConfig.serverUrl}/.well-known/oauth-protected-resource"`, }); - res.end(JSON.stringify({ error: "invalid_token", error_description: "Cal.com token expired and could not be refreshed" })); + res.end( + JSON.stringify({ + error: "invalid_token", + error_description: "Cal.com token expired and could not be refreshed", + }) + ); return; } existingSession.lastActivityAt = Date.now(); @@ -306,7 +338,12 @@ export async function startHttpServer( // Enforce max sessions limit if (sessions.size >= maxSessions) { res.writeHead(503, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "server_error", error_description: "Maximum number of sessions reached" })); + res.end( + JSON.stringify({ + error: "server_error", + error_description: "Maximum number of sessions reached", + }) + ); return; } @@ -316,7 +353,12 @@ export async function startHttpServer( "Content-Type": "application/json", "WWW-Authenticate": `Bearer resource_metadata="${oauthConfig.serverUrl}/.well-known/oauth-protected-resource"`, }); - res.end(JSON.stringify({ error: "invalid_token", error_description: "Invalid or expired access token" })); + res.end( + JSON.stringify({ + error: "invalid_token", + error_description: "Invalid or expired access token", + }) + ); return; } @@ -331,7 +373,7 @@ export async function startHttpServer( }, { instructions: SERVER_INSTRUCTIONS, - }, + } ); registerTools(server); @@ -354,7 +396,12 @@ export async function startHttpServer( const newSessionId = transport.sessionId; if (newSessionId) { - sessions.set(newSessionId, { transport, server, calAuthHeaders, lastActivityAt: Date.now() }); + sessions.set(newSessionId, { + transport, + server, + calAuthHeaders, + lastActivityAt: Date.now(), + }); logger.info("New session created", { sessionId: newSessionId }); } return; @@ -395,9 +442,11 @@ export async function startHttpServer( Array.from(sessions.entries()).map(async ([id, session]) => { try { await session.transport.close(); - } catch { /* best effort */ } + } catch { + /* best effort */ + } sessions.delete(id); - }), + }) ); const timeout = new Promise((resolve) => setTimeout(resolve, shutdownTimeoutMs)); @@ -409,9 +458,13 @@ export async function startHttpServer( }; process.on("SIGINT", () => { - shutdown().then(() => process.exit(0)).catch(() => process.exit(1)); + shutdown() + .then(() => process.exit(0)) + .catch(() => process.exit(1)); }); process.on("SIGTERM", () => { - shutdown().then(() => process.exit(0)).catch(() => process.exit(1)); + shutdown() + .then(() => process.exit(0)) + .catch(() => process.exit(1)); }); } diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index bff6017d..692b31e3 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -3,11 +3,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { getApiKeyHeaders } from "./auth.js"; -import { loadConfig } from "./config.js"; import type { HttpConfig, StdioConfig } from "./config.js"; +import { loadConfig } from "./config.js"; +import { startHttpServer } from "./http-server.js"; import { registerTools } from "./register-tools.js"; import { SERVER_INSTRUCTIONS } from "./server-instructions.js"; -import { startHttpServer } from "./http-server.js"; import { logger, setLogLevel } from "./utils/logger.js"; async function main(): Promise { @@ -34,6 +34,7 @@ async function main(): Promise { sessionIdleTimeoutMs: httpConfig.sessionIdleTimeoutMs, maxRegisteredClients: httpConfig.maxRegisteredClients, allowedRedirectHosts: httpConfig.allowedRedirectHosts, + allowOpenRedirectRegistration: httpConfig.allowOpenRedirectRegistration, corsOrigin: httpConfig.corsOrigin, shutdownTimeoutMs: httpConfig.shutdownTimeoutMs, openaiAppsChallengeToken: httpConfig.openaiAppsChallengeToken, @@ -51,7 +52,7 @@ async function main(): Promise { }, { instructions: SERVER_INSTRUCTIONS, - }, + } ); registerTools(server); diff --git a/apps/mcp-server/src/utils/http-security.test.ts b/apps/mcp-server/src/utils/http-security.test.ts new file mode 100644 index 00000000..ce30e0c2 --- /dev/null +++ b/apps/mcp-server/src/utils/http-security.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { applyCorsHeaders, resolveCorsOrigin } from "./http-security.js"; + +describe("resolveCorsOrigin", () => { + it("uses the configured CORS origin when present", () => { + expect(resolveCorsOrigin("https://client.example", "https://mcp.example.com")).toBe( + "https://client.example" + ); + }); + + it("defaults to the MCP server origin instead of wildcard", () => { + expect(resolveCorsOrigin(undefined, "https://mcp.example.com/mcp")).toBe( + "https://mcp.example.com" + ); + }); +}); + +describe("applyCorsHeaders", () => { + it("allows MCP browser client preflight headers", () => { + const headers = new Map(); + + applyCorsHeaders( + { + setHeader(name, value) { + headers.set(name, value); + }, + }, + "https://client.example" + ); + + expect(headers.get("Access-Control-Allow-Origin")).toBe("https://client.example"); + expect(headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, DELETE, OPTIONS"); + expect(headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type, Authorization, Mcp-Session-Id, mcp-protocol-version, last-event-id" + ); + expect(headers.get("Access-Control-Expose-Headers")).toBe("Mcp-Session-Id"); + expect(headers.get("Access-Control-Allow-Credentials")).toBe("true"); + expect(headers.get("Vary")).toBe("Origin"); + }); +}); diff --git a/apps/mcp-server/src/utils/http-security.ts b/apps/mcp-server/src/utils/http-security.ts new file mode 100644 index 00000000..8d378e93 --- /dev/null +++ b/apps/mcp-server/src/utils/http-security.ts @@ -0,0 +1,21 @@ +type HeaderResponse = { + setHeader(name: string, value: string): void; +}; + +export function resolveCorsOrigin(configuredOrigin: string | undefined, serverUrl: string): string { + if (configuredOrigin) return configuredOrigin; + return new URL(serverUrl).origin; +} + +export function applyCorsHeaders(res: HeaderResponse, origin: string): void { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + // The MCP browser client sends these custom headers; preflight must allow them. + 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"); +} diff --git a/apps/mcp-server/src/vercel-handler.ts b/apps/mcp-server/src/vercel-handler.ts index 92411f38..fef85318 100644 --- a/apps/mcp-server/src/vercel-handler.ts +++ b/apps/mcp-server/src/vercel-handler.ts @@ -4,24 +4,25 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; import { authContext } from "./auth/context.js"; -import { - buildAuthorizationServerMetadata, - buildProtectedResourceMetadata, -} from "./auth/oauth-metadata.js"; import { handleAuthorize, handleCallback, handleRegister, handleRevoke, handleToken, - resolveCalAuthHeaders, type OAuthConfig, + resolveCalAuthHeaders, } from "./auth/oauth-handlers.js"; -import { loadConfig, type HttpConfig } from "./config.js"; +import { + buildAuthorizationServerMetadata, + buildProtectedResourceMetadata, +} from "./auth/oauth-metadata.js"; +import { type HttpConfig, loadConfig } 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"; +import { applyCorsHeaders, resolveCorsOrigin } from "./utils/http-security.js"; /** * Vercel serverless entry point. @@ -74,22 +75,13 @@ function oauthConfigFromHttpConfig(config: HttpConfig): OAuthConfig { }; } -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"); - // 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"); +function setCorsHeaders( + res: ServerResponse, + corsOrigin: string | undefined, + serverUrl: string +): void { + const origin = resolveCorsOrigin(corsOrigin, serverUrl); + applyCorsHeaders(res, origin); } function jsonError(res: ServerResponse, status: number, error: string, description?: string): void { @@ -102,7 +94,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) const oauthConfig = oauthConfigFromHttpConfig(config); await ensureDb(); - setCorsHeaders(req, res, config.corsOrigin); + setCorsHeaders(res, config.corsOrigin, config.serverUrl); if (req.method === "OPTIONS") { res.writeHead(204); @@ -163,7 +155,10 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) jsonError(res, 503, "server_error", "Maximum number of registered clients reached"); return; } - await handleRegister(req, res, { allowedHosts: config.allowedRedirectHosts }); + await handleRegister(req, res, { + allowedHosts: config.allowedRedirectHosts, + allowExternalRedirectsWithoutAllowlist: config.allowOpenRedirectRegistration, + }); return; } if (url.pathname === "/oauth/authorize" && req.method === "GET") { @@ -205,7 +200,9 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) "Content-Type": "application/json", "WWW-Authenticate": `Bearer resource_metadata="${oauthConfig.serverUrl.replace(/\/+$/, "")}/.well-known/oauth-protected-resource"`, }); - res.end(JSON.stringify({ error: "unauthorized", error_description: "Bearer token required" })); + res.end( + JSON.stringify({ error: "unauthorized", error_description: "Bearer token required" }) + ); return; } @@ -215,7 +212,12 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) "Content-Type": "application/json", "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" })); + res.end( + JSON.stringify({ + error: "invalid_token", + error_description: "Invalid or expired access token", + }) + ); return; } @@ -232,7 +234,12 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) // 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" })); + res.end( + JSON.stringify({ + error: "method_not_allowed", + error_description: "SSE stream not supported in serverless mode", + }) + ); return; } @@ -265,7 +272,9 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) return; } - const messages: JSONRPCMessage[] = (Array.isArray(rawMessage) ? rawMessage : [rawMessage]) as JSONRPCMessage[]; + const messages: JSONRPCMessage[] = ( + Array.isArray(rawMessage) ? rawMessage : [rawMessage] + ) as JSONRPCMessage[]; // JSON-RPC requests have an `id` field; notifications do not. const requestIds = messages @@ -282,7 +291,9 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) // -- 2. Build minimal Transport -------------------------------------------- const collectedResponses = new Map(); let resolveAll!: () => void; - const allDone = new Promise((r) => { resolveAll = r; }); + const allDone = new Promise((r) => { + resolveAll = r; + }); const transport: Transport = { // These callbacks are set by McpServer.connect() before start() returns. @@ -290,8 +301,12 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) onclose: undefined, onerror: undefined, - async start() { /* nothing to set up */ }, - async close() { transport.onclose?.(); }, + async start() { + /* nothing to set up */ + }, + async close() { + transport.onclose?.(); + }, async send(msg: JSONRPCMessage) { // Only capture responses (they have `id` + `result`/`error`, but no `method`); @@ -306,7 +321,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) // -- 3. Connect McpServer and drive messages -------------------------------- const server = new McpServer( { name: "calcom-mcp-server", version: "0.1.0" }, - { instructions: SERVER_INSTRUCTIONS }, + { instructions: SERVER_INSTRUCTIONS } ); registerTools(server); await server.connect(transport); @@ -322,7 +337,10 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) await Promise.race([ allDone, new Promise((_, reject) => - setTimeout(() => reject(new Error(`MCP handler timed out after ${timeoutMs} ms`)), timeoutMs), + setTimeout( + () => reject(new Error(`MCP handler timed out after ${timeoutMs} ms`)), + timeoutMs + ) ), ]); }); @@ -333,7 +351,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) // -- 4. Return JSON -------------------------------------------------------- const responsePayload = Array.isArray(rawMessage) ? requestIds.map((id) => collectedResponses.get(id) ?? null) - : collectedResponses.get(requestIds[0]) ?? null; + : (collectedResponses.get(requestIds[0]) ?? null); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(responsePayload));