Skip to content
Merged
7 changes: 4 additions & 3 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Quality gate for changes targeting main.
#
# Runs lint, build, and tests on pull_request events only.
# Runs lint, build, and tests with coverage on pull_request events only.
# Coverage floors come from vitest.config.ts.
# Intentionally does not run on push to main: that would re-execute the same
# suite after every merge. Post-merge soft detection of direct pushes lives in
# main-direct-push-alert.yml (alert only; not a substitute for branch protection).
Expand Down Expand Up @@ -62,5 +63,5 @@ jobs:
- name: Install dependencies
run: pnpm install --ignore-scripts --frozen-lockfile

- name: Test
run: pnpm test
- name: Test with coverage
run: pnpm test:coverage
52 changes: 38 additions & 14 deletions ARCHITECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ This document describes the communication flow between an MCP client and OpenAPI

`openapi-contract` is a **native MCP server** built with [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk). There is no child process or NDJSON proxy; the MCP protocol is handled directly over stdio.

This release is **read-only**: the server fetches, caches, and queries OpenAPI documents so agents can build against the real API shape. It does **not** execute HTTP calls against API operations.
By default the server is **read-only**: it fetches, caches, and queries OpenAPI documents so agents can build against the real API shape. Optional HTTP execution (`call_endpoint`) is registered only when `OPENAPI_MCP_ENABLE_CALLS` is truthy (`1` / `true` / `yes`).

| Responsibility | Where |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Env → TTLs / registry path | [`src/config.ts`](src/config.ts) |
| Env → TTLs / registry / call limits | [`src/config.ts`](src/config.ts) |
| Boot + tool registration + stdio | [`src/index.ts`](src/index.ts) |
| Thin MCP tool adapters (Zod) | [`src/tools/*`](src/tools) |
| Orchestration façade | [`src/service.ts`](src/service.ts) |
| Disk backend registry (metadata only) | [`src/registry.ts`](src/registry.ts) |
| In-memory OpenAPI cache | [`src/openapi/cache.ts`](src/openapi/cache.ts) |
| Spec fetch / discovery / parse | [`src/openapi/fetch.ts`](src/openapi/fetch.ts) |
| Call URL + HTTP response helpers | [`src/openapi/call-url.ts`](src/openapi/call-url.ts), [`src/openapi/call.ts`](src/openapi/call.ts) |
| Index, deref, request examples | [`src/openapi/index-ops.ts`](src/openapi/index-ops.ts), [`src/openapi/deref.ts`](src/openapi/deref.ts), [`src/openapi/example.ts`](src/openapi/example.ts) |

---
Expand All @@ -25,22 +26,24 @@ This release is **read-only**: the server fetches, caches, and queries OpenAPI d

```mermaid
flowchart TB
Client[Cursor MCP client]
Client[MCP client]
Stdio[Stdio transport]
Index[Entrypoint and bootstrap]
Tools[MCP tool adapters]
Service[Contract orchestration service]
Registry[Backend registry]
Cache[In-memory OpenAPI cache]
Fetch[Spec discovery and fetch]
Call[Call URL and HTTP helpers]
OpenAPI[Index deref and examples]
Disk[(On-disk registry store)]
Backend[(Backend OpenAPI endpoint)]
Backend[(Backend OpenAPI and API)]

Client --> Stdio --> Index --> Tools --> Service
Service --> Registry --> Disk
Service --> Cache
Service --> Fetch --> Backend
Service --> Call --> Backend
Service --> OpenAPI
Fetch --> Cache
```
Expand Down Expand Up @@ -89,7 +92,23 @@ sequenceDiagram
T-->>C: JSON result
```

API HTTP **execution** (calling an operation against the live backend) is out of scope. The server only reads the contract.
When `OPENAPI_MCP_ENABLE_CALLS` is enabled, agents may also execute an operation:

```mermaid
sequenceDiagram
participant C as MCP client
participant T as call_endpoint tool
participant S as Contract service
participant B as API backend

C->>T: call_endpoint backendId operation selector params auth
T->>S: callEndpoint
S->>S: requireBackend findOperation buildCallUrl merge headers
S->>B: HTTP method URL
B-->>S: status headers body
S-->>T: status headers body url method truncated
T-->>C: JSON result
```

---

Expand All @@ -99,37 +118,42 @@ API HTTP **execution** (calling an operation against the live backend) is out of

The server uses `@modelcontextprotocol/sdk`'s `McpServer` with `StdioServerTransport`. There is no child process or NDJSON proxy. Errors go to stderr; stdout is reserved for MCP framing.

### 2. Read-only (contract inspection only)
### 2. Read-only by default; optional execute

Tools fetch, parse, index, dereference, and summarize OpenAPI documents. They do not invoke API endpoints on the registered backend. That keeps the surface safe and focused on shape discovery for frontend/mobile agents.
Contract tools always fetch, parse, index, dereference, and summarize OpenAPI documents. `call_endpoint` is **not registered** unless `OPENAPI_MCP_ENABLE_CALLS` is truthy. Secrets are never stored in the registry: callers pass `headers` and/or `headerEnv` per call. Request bodies are not validated against OpenAPI schemas (the backend validates).

### 3. On-demand backends

There is no env list of backends. Agents call `use_backend` with a `baseUrl` (and optional `id` / `specPath`). The registry persists metadata on disk and renews `lastUsedAt` on each successful use.

### 4. Dual TTL
### 4. Dual TTL (+ call limits)

- **Spec cache** (in-memory, default 60s via `OPENAPI_MCP_CACHE_TTL_MS`): holds the OpenAPI document; invalidated on `use` / `refresh` / `forget`.
- **Backend registry** (on disk, default 1 day via `OPENAPI_MCP_REGISTRY_TTL_MS`): stores `{ id, baseUrl, specPath?, lastUsedAt }` only; expired entries are pruned on load.
- **Call timeout / body cap** (`OPENAPI_MCP_CALL_TIMEOUT_MS`, `OPENAPI_MCP_CALL_MAX_BODY_BYTES`): apply only to `call_endpoint`.

OpenAPI documents are never written to the registry file.

### 5. Thin tools, fat service

Tool modules under `src/tools/` validate inputs with Zod and wrap results as JSON text / `isError`. `OpenApiContractService` owns orchestration across registry, cache, fetch, index, deref, and examples.
Tool modules under `src/tools/` validate inputs with Zod and wrap results as JSON text / `isError`. `OpenApiContractService` owns orchestration across registry, cache, fetch, index, deref, examples, and optional HTTP calls. Spec discovery (`fetch.ts`) stays separate from operation execution (`call.ts` / `call-url.ts`).

### 6. Spec discovery

Default path is `/docs-json` (Nest Swagger). Ordered fallbacks: `/docs-yaml` → `/openapi.json` → `/v3/api-docs`. Absolute document URLs are accepted and split into origin + `specPath`.

### 7. Local `$ref` dereference only
### 7. Call URL assembly

`call_endpoint` builds URLs as `baseUrl` + optional relative (or same-origin) `servers[0]` prefix + operation path (with path params and query). Absolute `servers` entries on a different origin are ignored so calls stay on the registered backend.

### 8. Local `$ref` dereference only

`deref` resolves local `#/` references. Cycles are annotated with `x-circular-ref`; external refs become `x-unresolved-ref` and are not fetched.

### 8. Agent-friendly errors
### 9. Agent-friendly errors

If a tool needs a backend that is missing or expired, the service returns a clear message telling the agent to call `use_backend` first (and similarly for missing operations).
If a tool needs a backend that is missing or expired, the service returns a clear message telling the agent to call `use_backend` first (and similarly for missing operations). For `call_endpoint`, HTTP 4xx/5xx are successful MCP payloads with `status`; `isError` is reserved for transport/local failures (timeout, missing path param, missing `headerEnv`, etc.).

### 9. Injectable seams for tests
### 10. Injectable seams for tests

`fetch` and clock/`now` dependencies can be injected so unit tests exercise registry TTL, cache behavior, and fetch discovery without a live network.
`fetch` and clock/`now` dependencies can be injected so unit tests exercise registry TTL, cache behavior, fetch discovery, and call_endpoint without a live network.
48 changes: 37 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenAPI Contract MCP

MCP server that reads **OpenAPI contracts** from local (or remote) backends so agents can build frontends and mobile apps against the real API shape. This release is **read-only**: it inspects the OpenAPI document and never executes HTTP calls against your API. Backends are registered on demand; there is no env list of backends.
MCP server that reads **OpenAPI contracts** from local (or remote) backends so agents can build frontends and mobile apps against the real API shape. By default it is **read-only** (inspects the OpenAPI document only). Optional HTTP execution via `call_endpoint` is available when you set `OPENAPI_MCP_ENABLE_CALLS`. Backends are registered on demand; there is no env list of backends.

[![npm](https://img.shields.io/npm/v/@fqueis/openapi-contract.svg)](https://www.npmjs.com/package/@fqueis/openapi-contract)
[![PR Checks](https://github.com/fqueis/openapi-contract/actions/workflows/pr-checks.yml/badge.svg)](https://github.com/fqueis/openapi-contract/actions/workflows/pr-checks.yml)
Expand All @@ -17,7 +17,7 @@ Built with [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcon
- **On-demand backend registration**: call `use_backend` with a `baseUrl`; nothing sensitive needs to live in a static backend list
- **Contract browsing**: overview, tags, operations, security schemes, and component schemas from the live spec
- **Dereferenced operations**: `get_operation` returns local `$ref` resolution plus a request example when possible
- **Read-only**: the server never executes API calls; it only reads and explains the OpenAPI shape
- **Read-only by default**: API execution is off unless `OPENAPI_MCP_ENABLE_CALLS` is set (then `call_endpoint` is registered)

---

Expand All @@ -29,9 +29,9 @@ Built with [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcon

---

## Usage in Cursor (`mcp.json`)
## Usage with an MCP client

Add to your Cursor MCP config (`~/.cursor/mcp.json` or Cursor Settings → MCP).
Register the server in your MCP client's config (stdio). Shape varies slightly by client; the examples below use a common `mcpServers` layout.

**Recommended (npm):**

Expand All @@ -49,6 +49,25 @@ Add to your Cursor MCP config (`~/.cursor/mcp.json` or Cursor Settings → MCP).

You can run the server from a local clone for development, but for normal use prefer the published package above.

**Optional: enable HTTP calls** (registers `call_endpoint`):

```json
{
"mcpServers": {
"openapi-contract": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@fqueis/openapi-contract"],
"env": {
"OPENAPI_MCP_ENABLE_CALLS": "1"
}
}
}
}
```

Prefer `headerEnv` (env var names) over pasting secrets into `headers` so tokens are less likely to appear in agent transcripts.

---

## Local development / contributors
Expand Down Expand Up @@ -96,9 +115,10 @@ pnpm test
2. `get_api_overview` / `list_tags` / `list_operations` / `search_operations`
3. `get_operation` for dereferenced schemas + request example
4. `get_schema` / `get_security` as needed
5. `forget_backend` or `clear_backends` to drop the on-disk registry; `refresh_backend` to refetch OpenAPI
5. With `OPENAPI_MCP_ENABLE_CALLS=1`: `call_endpoint` to execute an operation (auth via `headers` / `headerEnv`)
6. `forget_backend` or `clear_backends` to drop the on-disk registry; `refresh_backend` to refetch OpenAPI

If `baseUrl` is missing, tools return a clear error so the agent can ask you in the chat.
If `baseUrl` is missing, tools return a clear error so the agent can ask the user.

---

Expand All @@ -118,6 +138,7 @@ If `baseUrl` is missing, tools return a clear error so the agent can ask you in
| `search_operations` | Free-text search |
| `get_operation` | Full operation (dereferenced + example) |
| `get_schema` | Component schema by name or `$ref` |
| `call_endpoint` | Execute HTTP against an operation (only if ENABLE_CALLS) |

---

Expand All @@ -129,11 +150,16 @@ Default path: `/docs-json`. Fallbacks: `/docs-yaml` → `/openapi.json` → `/v3

## Optional env

| Variable | Default | Meaning |
| ----------------------------- | --------------------------------------------------- | ------------------------------------ |
| `OPENAPI_MCP_CACHE_TTL_MS` | `60000` | In-memory OpenAPI document TTL |
| `OPENAPI_MCP_REGISTRY_TTL_MS` | `86400000` | On-disk backend registry TTL (1 day) |
| `OPENAPI_MCP_REGISTRY_PATH` | `%USERPROFILE%\.openapi-contract-mcp\backends.json` | Registry file path |
MCP client configs (`mcp.json` and equivalents) pass `env` into the server process. Those values are always **strings** (same as OS/`process.env`). Write `"1"` or `"30000"`, not bare JSON booleans or numbers.

| Variable | Default | Meaning |
| --------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------- |
| `OPENAPI_MCP_CACHE_TTL_MS` | `60000` | In-memory OpenAPI document TTL |
| `OPENAPI_MCP_REGISTRY_TTL_MS` | `86400000` | On-disk backend registry TTL (1 day) |
| `OPENAPI_MCP_REGISTRY_PATH` | `%USERPROFILE%\.openapi-contract-mcp\backends.json` | Registry file path |
| `OPENAPI_MCP_ENABLE_CALLS` | unset (off) | When `"1"` / `"true"` / `"yes"`, register `call_endpoint` |
| `OPENAPI_MCP_CALL_TIMEOUT_MS` | `30000` | Abort timeout for `call_endpoint` requests |
| `OPENAPI_MCP_CALL_MAX_BODY_BYTES` | `102400` | Max response body bytes returned by `call_endpoint` (then truncated) |

---

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fqueis/openapi-contract",
"version": "1.0.0",
"version": "1.1.0",
"description": "MCP server that reads OpenAPI contracts from local backends for frontend/mobile agents",
"type": "module",
"main": "dist/index.js",
Expand Down
45 changes: 42 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/**
* Environment-backed runtime settings for the OpenAPI Contract MCP.
*
* Owns TTL units (milliseconds) and the on-disk registry path. Does not load
* backend lists from env: backends are registered on demand via tools.
* Owns TTL units (milliseconds), the on-disk registry path, and optional
* call_endpoint execution limits. Does not load backend lists from env:
* backends are registered on demand via tools. HTTP execution stays off unless
* OPENAPI_MCP_ENABLE_CALLS is truthy.
*/

import os from 'node:os';
Expand All @@ -14,6 +16,12 @@ export const DEFAULT_SPEC_CACHE_TTL_MS = 60_000;
/** Default backend registry TTL: 1 day. */
export const DEFAULT_REGISTRY_TTL_MS = 86_400_000;

/** Default timeout for call_endpoint HTTP requests: 30 seconds. */
export const DEFAULT_CALL_TIMEOUT_MS = 30_000;

/** Default max response body size returned by call_endpoint: 100 KiB. */
export const DEFAULT_CALL_MAX_BODY_BYTES = 102_400;

/** Default relative OpenAPI JSON path for Nest Swagger and similar stacks. */
export const DEFAULT_SPEC_PATH = '/docs-json';

Expand All @@ -39,17 +47,30 @@ export interface AppConfig {
* Absolute path to the backends JSON registry file.
*/
registryPath: string;
/**
* When true, the MCP registers `call_endpoint`. Default false (read-only).
*/
enableCalls: boolean;
/**
* Abort timeout for `call_endpoint` HTTP requests, in milliseconds.
*/
callTimeoutMs: number;
/**
* Maximum response body bytes returned by `call_endpoint` before truncation.
*/
callMaxBodyBytes: number;
}

/**
* Reads optional env overrides and returns a complete {@link AppConfig}.
*
* @returns Resolved TTLs and registry file path
* @returns Resolved TTLs, registry path, and call_endpoint opt-in settings
*
* @example
* ```typescript
* const config = loadConfig();
* // config.registryPath → %USERPROFILE%\.openapi-contract-mcp\backends.json
* // config.enableCalls → false unless OPENAPI_MCP_ENABLE_CALLS=1|true|yes
* ```
*/
export function loadConfig(): AppConfig {
Expand All @@ -59,6 +80,9 @@ export function loadConfig(): AppConfig {
registryPath:
process.env.OPENAPI_MCP_REGISTRY_PATH?.trim() ||
path.join(os.homedir(), '.openapi-contract-mcp', 'backends.json'),
enableCalls: parseTruthyEnv(process.env.OPENAPI_MCP_ENABLE_CALLS),
callTimeoutMs: parsePositiveInt(process.env.OPENAPI_MCP_CALL_TIMEOUT_MS, DEFAULT_CALL_TIMEOUT_MS),
callMaxBodyBytes: parsePositiveInt(process.env.OPENAPI_MCP_CALL_MAX_BODY_BYTES, DEFAULT_CALL_MAX_BODY_BYTES),
};
}

Expand All @@ -79,3 +103,18 @@ function parsePositiveInt(raw: string | undefined, fallback: number): number {
}
return parsed;
}

/**
* Treats `1`, `true`, and `yes` as enabled (case-insensitive). Missing or any
* other value is disabled.
*
* @param raw - Raw env value
* @returns Whether the flag should be considered on
*/
function parseTruthyEnv(raw: string | undefined): boolean {
if (raw === undefined) {
return false;
}
const normalized = raw.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
Loading