|
| 1 | +--- |
| 2 | +title: Align MCP Server Tools with the Cal.com API v2 OpenAPI Contract |
| 3 | +impact: HIGH |
| 4 | +impactDescription: Prevents MCP tool schemas from drifting away from the API they wrap |
| 5 | +tags: api, openapi, mcp, mcp-server, zod, api-v2 |
| 6 | +--- |
| 7 | + |
| 8 | +# Align MCP Server Tools with the Cal.com API v2 OpenAPI Contract |
| 9 | + |
| 10 | +**Impact: HIGH (prevents MCP tool schemas from drifting away from the API they wrap)** |
| 11 | + |
| 12 | +`apps/mcp-server` is a Model Context Protocol server that wraps the |
| 13 | +[Cal.com Platform API v2](https://cal.com/docs/api-reference/v2). Every tool's |
| 14 | +Zod input schema is effectively a re-declaration of part of the API v2 contract. |
| 15 | +If the schema drifts from the real endpoint, the LLM sends requests the API |
| 16 | +rejects (or, worse, silently malformed ones). Keep the two in sync. |
| 17 | + |
| 18 | +**This rule applies only to `apps/mcp-server`** (and any other code that wraps |
| 19 | +API v2). It does not apply to `apps/chat`, `apps/mobile`, `apps/extension`, or |
| 20 | +`packages/cli`. |
| 21 | + |
| 22 | +## Before adding or changing a tool |
| 23 | + |
| 24 | +1. **Locate the OpenAPI spec.** Find `docs/api-reference/v2/openapi.json` from a |
| 25 | + local `calcom/cal` checkout — see |
| 26 | + [reference-local-cal-api](reference-local-cal-api.md) for the lookup order. |
| 27 | +2. **Read the exact contract** for the endpoint you wrap: |
| 28 | + - HTTP path and method. |
| 29 | + - Path params and query params. |
| 30 | + - Request body schema (follow `$ref`s). |
| 31 | + - Response schema (follow `$ref`s). |
| 32 | + - Enums, `minimum`/`maximum`, `default`, and `required` fields. |
| 33 | +3. **Mirror those constraints in the Zod schema**, unless you intentionally |
| 34 | + choose a stricter bound for safety (document why in a comment if non-obvious). |
| 35 | + |
| 36 | +## Mirror constraints in Zod |
| 37 | + |
| 38 | +Schemas live next to each tool in `apps/mcp-server/src/tools/**` and are exported |
| 39 | +as `<tool>Schema` objects. Encode the documented bounds directly: |
| 40 | + |
| 41 | +**Incorrect (loose schema that ignores the documented bounds):** |
| 42 | + |
| 43 | +```typescript |
| 44 | +export const getOrgMembershipsSchema = { |
| 45 | + orgId: z.number().describe("Organization ID"), |
| 46 | + take: z.number().optional(), |
| 47 | + skip: z.number().optional(), |
| 48 | +}; |
| 49 | +``` |
| 50 | + |
| 51 | +**Correct (mirrors path/param types and the OpenAPI min/max + integer bounds):** |
| 52 | + |
| 53 | +```typescript |
| 54 | +export const getOrgMembershipsSchema = { |
| 55 | + orgId: z.number().int().describe("Organization ID. Use get_me — never guess."), |
| 56 | + take: z.number().int().min(1).max(250).optional().describe("Max results (1-250)"), |
| 57 | + skip: z.number().int().min(0).optional().describe("Results to skip (offset, min 0)"), |
| 58 | +}; |
| 59 | +``` |
| 60 | + |
| 61 | +Match documented enums exactly (e.g. `z.enum(["MEMBER", "OWNER", "ADMIN"])`) and |
| 62 | +keep `.describe()` text accurate — the descriptions become the tool's parameter |
| 63 | +docs that the LLM reads. |
| 64 | + |
| 65 | +## Update discovery + metadata when behavior changes |
| 66 | + |
| 67 | +When a change affects what a tool can do or how it is discovered, update all of: |
| 68 | + |
| 69 | +- Tool annotations (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, |
| 70 | + `openWorldHint`) so they still reflect the operation. |
| 71 | +- The tool tables in `apps/mcp-server/README.md`. |
| 72 | +- Server instructions in `apps/mcp-server/src/server-instructions.ts`. |
| 73 | + |
| 74 | +## Add tests for request shape and validation boundaries |
| 75 | + |
| 76 | +Co-located `*.test.ts` files already assert OpenAPI bounds — extend them. Cover |
| 77 | +the schema edges so future edits can't silently loosen them: |
| 78 | + |
| 79 | +```typescript |
| 80 | +it("enforces OpenAPI pagination bounds", () => { |
| 81 | + expect(getOrgMembershipsSchema.take.safeParse(0).success).toBe(false); |
| 82 | + expect(getOrgMembershipsSchema.take.safeParse(250).success).toBe(true); |
| 83 | + expect(getOrgMembershipsSchema.take.safeParse(251).success).toBe(false); |
| 84 | + expect(getOrgMembershipsSchema.skip.safeParse(-1).success).toBe(false); |
| 85 | +}); |
| 86 | +``` |
| 87 | + |
| 88 | +Run them with `bun --filter @calcom/mcp-server test` (or `vitest run` inside |
| 89 | +`apps/mcp-server`). |
| 90 | + |
| 91 | +## Optional: local stdio smoke |
| 92 | + |
| 93 | +For high-confidence MCP PRs, run a local stdio smoke against API v2 when a local |
| 94 | +API key/DB is available (see `apps/mcp-server/README.md` for stdio + `CAL_API_KEY` |
| 95 | +setup) to confirm the tool actually round-trips against the live contract. |
| 96 | + |
| 97 | +## Do not hand-edit the spec |
| 98 | + |
| 99 | +`docs/api-reference/v2/openapi.json` is generated in `calcom/cal`. If the spec is |
| 100 | +wrong, fix/regenerate it in `/cal` separately — never hand-edit that JSON from |
| 101 | +Companion. |
0 commit comments