Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions apps/mcp-server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 10 additions & 8 deletions apps/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -166,22 +168,22 @@ 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 <access_token>`

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`)
- PKCE (S256) is required on both legs (client→server and server→Cal.com)
- 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.

Expand Down
30 changes: 20 additions & 10 deletions apps/mcp-server/src/auth/oauth-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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", () => {
Expand All @@ -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);
});

Expand Down
Loading
Loading