From 94c34a4fc798209d5028b5d64193bf21f42bf868 Mon Sep 17 00:00:00 2001 From: Felipe Queis Date: Thu, 23 Jul 2026 23:09:14 -0300 Subject: [PATCH 1/9] Implement HTTP execution helpers for API calls - Added `call.ts` with functions for merging headers, materializing call results, serializing request bodies, and flattening headers. - Introduced `CallEndpointResult` type for structured API call responses. --- src/openapi/call.ts | 133 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/openapi/call.ts diff --git a/src/openapi/call.ts b/src/openapi/call.ts new file mode 100644 index 0000000..304c03c --- /dev/null +++ b/src/openapi/call.ts @@ -0,0 +1,133 @@ +/** + * HTTP execution helpers for call_endpoint (auth merge and response parsing). + * + * Does not own OpenAPI discovery or URL assembly. Secrets are never persisted; + * callers pass literal headers and/or env var names per request. + */ + +/** + * Result shape returned to tools after an API call completes. + */ +export type CallEndpointResult = { + /** HTTP status code from the backend. */ + status: number; + /** Response headers as a plain lowercase-key map (multi-values joined). */ + headers: Record; + /** Parsed JSON when Content-Type is JSON and body is intact; otherwise string. */ + body: unknown; + /** Final request URL. */ + url: string; + /** Uppercase HTTP method. */ + method: string; + /** True when the returned body was cut to callMaxBodyBytes. */ + truncated: boolean; +}; + +/** + * Merges literal headers with values resolved from process.env via headerEnv. + * Values from headerEnv overwrite the same header name from headers. + * + * @param headers - Literal header map from the tool call + * @param headerEnv - Map of header name → env var name + * @param env - Env source (defaults to process.env; injectable for tests) + * @returns Merged header record + * @throws Error when a referenced env var is missing or empty + * + * @example + * ```typescript + * mergeCallHeaders( + * { Authorization: 'Bearer literal' }, + * { Authorization: 'API_TOKEN' }, + * { API_TOKEN: 'from-env' }, + * ); + * // → { Authorization: 'from-env' } + * ``` + */ +export function mergeCallHeaders( + headers: Record | undefined, + headerEnv: Record | undefined, + env: NodeJS.ProcessEnv = process.env, +): Record { + const merged: Record = { ...(headers ?? {}) }; + for (const [headerName, envName] of Object.entries(headerEnv ?? {})) { + const value = env[envName]; + if (value === undefined || value.trim() === '') { + throw new Error( + `Environment variable "${envName}" for header "${headerName}" is missing or empty. ` + + `Set it in the MCP server process env, or pass the value via headers instead.`, + ); + } + merged[headerName] = value; + } + return merged; +} + +/** + * Reads a fetch Response into a {@link CallEndpointResult} body/headers payload. + * + * Truncates raw text to `maxBodyBytes` before JSON parse attempts. Truncated + * bodies are always returned as strings (never partially parsed JSON). + * + * @param response - Fetch response + * @param meta - Request url/method and max body size + * @returns Normalized call result + */ +export async function materializeCallResult( + response: Response, + meta: { url: string; method: string; maxBodyBytes: number }, +): Promise { + const text = await response.text(); + const truncated = text.length > meta.maxBodyBytes; + const raw = truncated ? text.slice(0, meta.maxBodyBytes) : text; + const headerMap = headersToRecord(response.headers); + const contentType = headerMap['content-type'] ?? ''; + + let body: unknown = raw; + if (!truncated && contentType.toLowerCase().includes('application/json') && raw.length > 0) { + try { + body = JSON.parse(raw) as unknown; + } catch { + body = raw; + } + } + + return { + status: response.status, + headers: headerMap, + body, + url: meta.url, + method: meta.method, + truncated, + }; +} + +/** + * Serializes a request body for fetch: objects become JSON strings. + * + * @param body - Tool-provided body + * @returns Body init value and whether Content-Type should default to JSON + */ +export function serializeCallBody(body: unknown): { body?: string; defaultJson: boolean } { + if (body === undefined || body === null) { + return { defaultJson: false }; + } + if (typeof body === 'string') { + return { body, defaultJson: false }; + } + return { body: JSON.stringify(body), defaultJson: true }; +} + +/** + * Flattens Headers into a plain object (lowercase keys; joins duplicates). + * + * @param headers - Fetch Headers + * @returns Plain map + */ +function headersToRecord(headers: Headers): Record { + const out: Record = {}; + headers.forEach((value, key) => { + const existing = out[key.toLowerCase()]; + out[key.toLowerCase()] = existing ? `${existing}, ${value}` : value; + }); + return out; +} From 57f1fbea806a0516bdd9050d61cf3d4a9ce2c60f Mon Sep 17 00:00:00 2001 From: Felipe Queis Date: Thu, 23 Jul 2026 23:09:51 -0300 Subject: [PATCH 2/9] Enhance configuration for call_endpoint execution limits - Added support for enabling `call_endpoint` with a new `enableCalls` flag. - Introduced timeout and maximum response body size settings for HTTP requests via `callTimeoutMs` and `callMaxBodyBytes`. - Updated `loadConfig` function to read new environment variables for these settings. --- src/config.ts | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index 6f04321..ebdc8ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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'; @@ -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'; @@ -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 { @@ -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), }; } @@ -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'; +} From 3ee7a3c606570927627f4c5d3c1174b41eebc6f7 Mon Sep 17 00:00:00 2001 From: Felipe Queis Date: Thu, 23 Jul 2026 23:10:03 -0300 Subject: [PATCH 3/9] Register HTTP execution tools conditionally based on OPENAPI_MCP_ENABLE_CALLS flag. Updated main function to include call tools if enabled. --- src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index b2996a0..0980a8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ * * Starts an MCP server that exposes OpenAPI contract tools for agents building * frontends and mobile apps. Backends are registered on demand via use_backend. + * HTTP execution (`call_endpoint`) is registered only when + * OPENAPI_MCP_ENABLE_CALLS is truthy. */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -12,6 +14,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { loadConfig } from '@/config.js'; import { OpenApiContractService } from '@/service.js'; import { registerBackendTools } from '@tools/backends.js'; +import { registerCallToolsIfEnabled } from '@tools/call.js'; import { registerOperationTools } from '@tools/operations.js'; import { registerOverviewTools } from '@tools/overview.js'; import { registerSchemaTools } from '@tools/schemas.js'; @@ -36,6 +39,7 @@ async function main(): Promise { registerSecurityTools(server, service); registerOperationTools(server, service); registerSchemaTools(server, service); + registerCallToolsIfEnabled(server, service, config.enableCalls); const transport = new StdioServerTransport(); await server.connect(transport); From 3b52fde848eef987f7bcd40375de46fdaaf02807 Mon Sep 17 00:00:00 2001 From: Felipe Queis Date: Thu, 23 Jul 2026 23:10:17 -0300 Subject: [PATCH 4/9] Add call tools registration for HTTP execution - Introduced `registerCallTools` function to wire the `call_endpoint` tool to the MCP server. - Added `registerCallToolsIfEnabled` function to conditionally register call tools based on the `enableCalls` flag. - Enhanced input schema for `call_endpoint` to support various HTTP request parameters and headers. --- src/tools/call.ts | 86 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/tools/call.ts diff --git a/src/tools/call.ts b/src/tools/call.ts new file mode 100644 index 0000000..ea8d5b9 --- /dev/null +++ b/src/tools/call.ts @@ -0,0 +1,86 @@ +/** + * Registers the optional call_endpoint MCP tool when HTTP execution is enabled. + * + * Opt-in only: callers must gate registration with AppConfig.enableCalls so the + * default MCP surface stays read-only contract inspection. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import type { OpenApiContractService } from '@/service.js'; +import { errorResult, jsonResult } from '@tools/result.js'; + +/** + * Wires `call_endpoint` onto the MCP server. + * + * @param server - MCP server instance + * @param service - Contract application service + */ +export function registerCallTools(server: McpServer, service: OpenApiContractService): void { + server.registerTool( + 'call_endpoint', + { + title: 'Call endpoint', + description: + 'Execute an HTTP request against a registered backend operation. ' + + 'Provide operationId and/or method+path. Auth via headers and/or headerEnv ' + + '(env var names resolved in the MCP process; headerEnv wins on conflicts). ' + + 'HTTP 4xx/5xx are returned as normal results; transport failures are errors.', + inputSchema: { + backendId: z.string().describe('Registered backend id'), + method: z.string().optional().describe('HTTP method when selecting by method+path'), + path: z.string().optional().describe('OpenAPI path template when selecting by method+path'), + operationId: z.string().optional().describe('OpenAPI operationId'), + pathParams: z.record(z.string(), z.string()).optional().describe('Path template parameter values'), + query: z.record(z.string(), z.string()).optional().describe('Query string parameters'), + body: z.unknown().optional().describe('Request body (object serialized as JSON, or raw string)'), + headers: z.record(z.string(), z.string()).optional().describe('Literal request headers'), + headerEnv: z + .record(z.string(), z.string()) + .optional() + .describe('Header name → process env var name (overrides headers on conflict)'), + }, + }, + async (args) => { + try { + const { backendId, method, path, operationId } = args; + if (!operationId && !(method && path)) { + return errorResult(new Error('Provide operationId and/or both method and path.')); + } + return jsonResult( + await service.callEndpoint({ + backendId, + method, + path, + operationId, + pathParams: args.pathParams, + query: args.query, + body: args.body, + headers: args.headers, + headerEnv: args.headerEnv, + }), + ); + } catch (error) { + return errorResult(error); + } + }, + ); +} + +/** + * Registers call tools only when execution is enabled for this process. + * + * @param server - MCP server instance + * @param service - Contract application service + * @param enableCalls - From {@link AppConfig.enableCalls} + */ +export function registerCallToolsIfEnabled( + server: McpServer, + service: OpenApiContractService, + enableCalls: boolean, +): void { + if (enableCalls) { + registerCallTools(server, service); + } +} From eb8ab3066abcc4484f8163c64b2bc4c70ab06167 Mon Sep 17 00:00:00 2001 From: Felipe Queis Date: Thu, 23 Jul 2026 23:10:25 -0300 Subject: [PATCH 5/9] Implement callEndpoint method and buildCallUrl utility for HTTP execution - Added `callEndpoint` method to `OpenApiContractService` for executing HTTP requests against registered backends. - Introduced `buildCallUrl` utility for constructing absolute URLs from OpenAPI specifications. - Enhanced documentation for service orchestration and input types for better clarity on usage. --- src/openapi/call-url.ts | 137 ++++++++++++++++++++++++++++++++++++++++ src/service.ts | 121 ++++++++++++++++++++++++++++++++++- 2 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 src/openapi/call-url.ts diff --git a/src/openapi/call-url.ts b/src/openapi/call-url.ts new file mode 100644 index 0000000..53d6e84 --- /dev/null +++ b/src/openapi/call-url.ts @@ -0,0 +1,137 @@ +/** + * Pure URL assembly for call_endpoint against a registered backend. + * + * Joins registry baseUrl, an optional OpenAPI servers[0] prefix (relative or + * same-origin absolute), path templates, and query. Does not perform HTTP. + */ + +/** + * OpenAPI server entry used when resolving a call URL prefix. + */ +export type CallServer = { + /** Server URL from the OpenAPI document (relative or absolute). */ + url?: string; +}; + +/** + * Inputs for {@link buildCallUrl}. + */ +export type BuildCallUrlInput = { + /** Registered backend origin (no trailing slash required). */ + baseUrl: string; + /** OpenAPI `servers` list; only the first entry may contribute a prefix. */ + servers: CallServer[]; + /** OpenAPI path template (e.g. `/v1/users/{id}`). */ + pathTemplate: string; + /** Values for `{name}` segments in the path template. */ + pathParams?: Record; + /** Query string key/value pairs. */ + query?: Record; +}; + +/** + * Builds the absolute request URL for an OpenAPI operation call. + * + * Relative `servers[0].url` is joined under `baseUrl`. Absolute servers on a + * different origin are ignored (caller stays on the registered backend). + * Absolute servers on the same origin contribute their pathname as a prefix. + * + * @param input - Backend origin, servers, path template, and optional params + * @returns Absolute URL string + * @throws Error when a `{param}` in the path template has no `pathParams` value + * + * @example + * ```typescript + * buildCallUrl({ + * baseUrl: 'http://localhost:3000', + * servers: [{ url: '/api/v1' }], + * pathTemplate: '/users/{id}', + * pathParams: { id: '42' }, + * }); + * // → http://localhost:3000/api/v1/users/42 + * ``` + */ +export function buildCallUrl(input: BuildCallUrlInput): string { + const origin = new URL(input.baseUrl); + const prefix = resolveServerPrefix(origin, input.servers[0]?.url); + const path = substitutePathParams(input.pathTemplate, input.pathParams ?? {}); + const pathname = joinPath(prefix, path); + const url = new URL(pathname, origin); + for (const [key, value] of Object.entries(input.query ?? {})) { + url.searchParams.set(key, value); + } + return url.toString(); +} + +/** + * Resolves an optional servers[0] URL into a path prefix under the backend origin. + * + * @param origin - Registered backend origin + * @param serverUrl - Optional OpenAPI server URL + * @returns Pathname prefix without trailing slash, or empty string + */ +function resolveServerPrefix(origin: URL, serverUrl: string | undefined): string { + if (!serverUrl || serverUrl.trim() === '') { + return ''; + } + const trimmed = serverUrl.trim(); + if (trimmed.startsWith('/')) { + return trimTrailingSlash(trimmed); + } + try { + const absolute = new URL(trimmed); + if (absolute.origin !== origin.origin) { + return ''; + } + return trimTrailingSlash(absolute.pathname === '/' ? '' : absolute.pathname); + } catch { + return ''; + } +} + +/** + * Replaces `{name}` segments using pathParams. + * + * @param pathTemplate - OpenAPI path template + * @param pathParams - Provided path parameter values + * @returns Path with substitutions applied + * @throws Error when a required template parameter is missing + */ +function substitutePathParams(pathTemplate: string, pathParams: Record): string { + return pathTemplate.replace(/\{([^}/]+)\}/g, (_match, name: string) => { + const value = pathParams[name]; + if (value === undefined) { + throw new Error(`Missing required path parameter "${name}" for path "${pathTemplate}".`); + } + return encodeURIComponent(value); + }); +} + +/** + * Joins a server prefix and operation path with normalized slashes. + * + * @param prefix - Optional server pathname prefix + * @param path - Operation path (usually starts with `/`) + * @returns Combined pathname starting with `/` + */ +function joinPath(prefix: string, path: string): string { + const left = trimTrailingSlash(prefix); + const right = path.startsWith('/') ? path : `/${path}`; + if (!left) { + return right; + } + return `${left}${right}`; +} + +/** + * Removes a single trailing slash unless the value is only `/`. + * + * @param value - Path or prefix + * @returns Value without a trailing slash + */ +function trimTrailingSlash(value: string): string { + if (value.length > 1 && value.endsWith('/')) { + return value.slice(0, -1); + } + return value; +} diff --git a/src/service.ts b/src/service.ts index e76be0b..25d62d4 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1,12 +1,15 @@ /** - * Application service that orchestrates registry, OpenAPI fetch/cache, and - * contract queries for MCP tools. + * Application service that orchestrates registry, OpenAPI fetch/cache, contract + * queries, and optional call_endpoint HTTP execution for MCP tools. * * Does not own transport or tool registration; tools call into this seam. + * Does not persist secrets or validate request bodies against OpenAPI schemas. */ import type { AppConfig } from '@/config.js'; import { SpecCache } from '@openapi/cache.js'; +import { materializeCallResult, mergeCallHeaders, serializeCallBody, type CallEndpointResult } from '@openapi/call.js'; +import { buildCallUrl } from '@openapi/call-url.js'; import { dereferenceSchema } from '@openapi/deref.js'; import { buildRequestExample } from '@openapi/example.js'; import { fetchOpenApiDocument } from '@openapi/fetch.js'; @@ -34,7 +37,31 @@ export function missingBackendMessage(backendId: string): string { } /** - * Orchestrates backend registry + OpenAPI contract reads for MCP tools. + * Input for {@link OpenApiContractService.callEndpoint}. + */ +export type CallEndpointInput = { + /** Registered backend id. */ + backendId: string; + /** HTTP method when selecting by method+path. */ + method?: string; + /** OpenAPI path template when selecting by method+path. */ + path?: string; + /** OpenAPI operationId when selecting by id. */ + operationId?: string; + /** Values for `{name}` path template segments. */ + pathParams?: Record; + /** Query string parameters. */ + query?: Record; + /** JSON object or raw string body. */ + body?: unknown; + /** Literal request headers. */ + headers?: Record; + /** Header name → process.env key (wins over `headers` on conflict). */ + headerEnv?: Record; +}; + +/** + * Orchestrates backend registry, OpenAPI contract reads, and optional HTTP calls. */ export class OpenApiContractService { readonly registry: BackendRegistry; @@ -296,6 +323,68 @@ export class OpenApiContractService { }; } + /** + * Executes an HTTP request against a registered backend operation. + * + * Resolves the operation from the OpenAPI document, builds the URL from + * `baseUrl` plus an optional relative/same-origin `servers[0]` prefix, merges + * auth headers, and returns status/body even for HTTP 4xx/5xx. Transport + * failures (timeout, missing path param, missing headerEnv) throw. + * + * @param input - Backend, operation selector, params, body, and auth + * @returns Normalized HTTP result for the agent + */ + async callEndpoint(input: CallEndpointInput): Promise { + const backend = await this.requireBackend(input.backendId); + const { document } = await this.getDocument(input.backendId); + const indexed = findOperation(indexOperations(input.backendId, document), { + method: input.method, + path: input.path, + operationId: input.operationId, + }); + if (!indexed) { + throw new Error(operationNotFoundMessage(input)); + } + + const url = buildCallUrl({ + baseUrl: backend.baseUrl, + servers: document.servers ?? [], + pathTemplate: indexed.path, + pathParams: input.pathParams, + query: input.query, + }); + + const headers = mergeCallHeaders(input.headers, input.headerEnv); + const serialized = serializeCallBody(input.body); + if (serialized.defaultJson && !hasHeaderIgnoreCase(headers, 'content-type')) { + headers['Content-Type'] = 'application/json'; + } + + const method = indexed.method; + let response: Response; + try { + response = await this.fetchImpl(url, { + method, + headers, + body: serialized.body, + signal: AbortSignal.timeout(this.config.callTimeoutMs), + }); + } catch (error) { + if (isAbortError(error)) { + throw new Error(`call_endpoint timed out after ${this.config.callTimeoutMs}ms for ${method} ${url}.`, { + cause: error, + }); + } + throw error; + } + + return materializeCallResult(response, { + url, + method, + maxBodyBytes: this.config.callMaxBodyBytes, + }); + } + /** * Returns a dereferenced component schema by name or `#/…` ref. * @@ -433,3 +522,29 @@ function mapContentSchemas( } return out; } + +/** + * Checks whether a header name exists in a record (case-insensitive). + * + * @param headers - Header map + * @param name - Header name to find + * @returns True when present + */ +function hasHeaderIgnoreCase(headers: Record, name: string): boolean { + const target = name.toLowerCase(); + return Object.keys(headers).some((key) => key.toLowerCase() === target); +} + +/** + * Detects AbortSignal timeout / abort errors across runtimes. + * + * @param error - Caught value + * @returns True when the failure is an abort/timeout + */ +function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + const name = (error as { name?: string }).name; + return name === 'TimeoutError' || name === 'AbortError'; +} From 8a23f35b2f3ab19b2c3adf6ca5f3f47a6ba811c4 Mon Sep 17 00:00:00 2001 From: Felipe Queis Date: Thu, 23 Jul 2026 23:10:34 -0300 Subject: [PATCH 6/9] Add comprehensive tests for callEndpoint, call tools registration, and URL building - Introduced tests for `OpenApiContractService.callEndpoint` covering various scenarios including successful calls, error handling, and response body truncation. - Added tests for `registerCallToolsIfEnabled` to verify conditional registration of call tools based on the `enableCalls` flag. - Implemented tests for `buildCallUrl` to ensure correct URL assembly from base URL and path templates, including handling of path parameters and query string encoding. - Enhanced configuration tests to validate new environment variables for call execution limits and enabling calls. --- tests/call-endpoint.spec.ts | 345 ++++++++++++++++++++++++++++++++++++ tests/call-tools.spec.ts | 112 ++++++++++++ tests/call-url.spec.ts | 82 +++++++++ tests/config.spec.ts | 53 +++++- tests/service.spec.ts | 89 ++++++++++ 5 files changed, 677 insertions(+), 4 deletions(-) create mode 100644 tests/call-endpoint.spec.ts create mode 100644 tests/call-tools.spec.ts create mode 100644 tests/call-url.spec.ts diff --git a/tests/call-endpoint.spec.ts b/tests/call-endpoint.spec.ts new file mode 100644 index 0000000..1a9a5df --- /dev/null +++ b/tests/call-endpoint.spec.ts @@ -0,0 +1,345 @@ +/** + * Seam under test: OpenApiContractService.callEndpoint (HTTP execution). + * + * Covers: + * 1. Successful JSON call against a registered operation + * 2. HTTP 4xx still returns a result (does not throw) + * 3. Missing path params throw before fetch + * 4. Missing headerEnv values throw before fetch + * 5. headerEnv overlays headers on the same header name + * 6. Response body truncation when over callMaxBodyBytes + * 7. TimeoutError / AbortError map to a timeout message + * 8. Non-abort fetch failures are rethrown + * 9. Missing operation throws before fetch + * 10. Existing Content-Type is preserved when serializing a JSON body + * 11. String bodies are sent without forcing application/json + * + * Out of scope: MCP tool registration; pure URL builder edge cases (call-url.spec). + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createService } from './helpers/create-service.js'; +import { requestUrl } from './helpers/request-url.js'; +import { sampleOpenApi } from './fixtures/sample-openapi.js'; + +describe('OpenApiContractService.callEndpoint', () => { + const cleanups: Array<() => Promise> = []; + const previousEnv = new Map(); + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((fn) => fn())); + for (const [key, value] of previousEnv) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + previousEnv.clear(); + }); + + it('executes a JSON operation and returns status, headers, body, url, method', async () => { + const { service, apiCalls } = await setupWithApi(); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + + const result = await service.callEndpoint({ + backendId: 'demo', + operationId: 'login', + body: { email: 'a@b.com', password: 'secret' }, + }); + + expect(apiCalls).toEqual(['http://localhost:3000/v1/auth/login']); + expect(result).toMatchObject({ + status: 200, + method: 'POST', + url: 'http://localhost:3000/v1/auth/login', + body: { accessToken: 'tok' }, + truncated: false, + }); + expect(result.headers['content-type']).toMatch(/application\/json/i); + }); + + it('returns HTTP 401 as a normal result without throwing', async () => { + const { service } = await setupWithApi({ + apiHandler: () => + Promise.resolve( + new Response(JSON.stringify({ message: 'Unauthorized' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }), + ), + }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + + const result = await service.callEndpoint({ + backendId: 'demo', + method: 'GET', + path: '/v1/users/{id}', + pathParams: { id: '1' }, + }); + + expect(result.status).toBe(401); + expect(result.body).toEqual({ message: 'Unauthorized' }); + }); + + it('throws when a required path parameter is missing', async () => { + const { service, apiCalls } = await setupWithApi(); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + + await expect( + service.callEndpoint({ + backendId: 'demo', + operationId: 'getUser', + }), + ).rejects.toThrow(/path parameter "id"/i); + expect(apiCalls).toEqual([]); + }); + + it('throws when a headerEnv variable is missing', async () => { + const { service, apiCalls } = await setupWithApi(); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + snapshotEnv('MISSING_TOKEN'); + delete process.env.MISSING_TOKEN; + + await expect( + service.callEndpoint({ + backendId: 'demo', + operationId: 'getUser', + pathParams: { id: '1' }, + headerEnv: { Authorization: 'MISSING_TOKEN' }, + }), + ).rejects.toThrow(/MISSING_TOKEN/); + expect(apiCalls).toEqual([]); + }); + + it('lets headerEnv win over literal headers for the same name', async () => { + const captured: Array<{ headers: Headers }> = []; + const { service } = await setupWithApi({ + apiHandler: (input, init) => { + captured.push({ headers: new Headers(init?.headers) }); + return Promise.resolve( + new Response(JSON.stringify({ id: '1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }, + }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + snapshotEnv('API_TOKEN'); + process.env.API_TOKEN = 'from-env'; + + await service.callEndpoint({ + backendId: 'demo', + operationId: 'getUser', + pathParams: { id: '1' }, + headers: { Authorization: 'Bearer literal' }, + headerEnv: { Authorization: 'API_TOKEN' }, + }); + + expect(captured[0]?.headers.get('Authorization')).toBe('from-env'); + }); + + it('truncates oversized response bodies and sets truncated true', async () => { + const big = 'x'.repeat(50); + const { service } = await setupWithApi({ + callMaxBodyBytes: 10, + apiHandler: () => + Promise.resolve( + new Response(big, { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ), + }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + + const result = await service.callEndpoint({ + backendId: 'demo', + operationId: 'getUser', + pathParams: { id: '1' }, + }); + + expect(result.truncated).toBe(true); + expect(result.body).toBe('xxxxxxxxxx'); + }); + + it('maps TimeoutError from fetch into a call_endpoint timeout message', async () => { + const { service } = await setupWithApi({ + apiHandler: () => { + const error = new Error('aborted'); + error.name = 'TimeoutError'; + return Promise.reject(error); + }, + }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + + await expect( + service.callEndpoint({ + backendId: 'demo', + operationId: 'login', + body: { email: 'a@b.com', password: 'x' }, + }), + ).rejects.toThrow(/timed out after \d+ms/); + }); + + it('maps AbortError from fetch into a call_endpoint timeout message', async () => { + const { service } = await setupWithApi({ + apiHandler: () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + return Promise.reject(error); + }, + }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + + await expect( + service.callEndpoint({ + backendId: 'demo', + operationId: 'getUser', + pathParams: { id: '1' }, + }), + ).rejects.toThrow(/timed out after/); + }); + + it('rethrows non-abort fetch failures', async () => { + const { service } = await setupWithApi({ + apiHandler: () => Promise.reject(new Error('ECONNREFUSED')), + }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + + await expect( + service.callEndpoint({ + backendId: 'demo', + operationId: 'getUser', + pathParams: { id: '1' }, + }), + ).rejects.toThrow('ECONNREFUSED'); + }); + + it('throws when the operation lookup misses before calling the API', async () => { + const { service, apiCalls } = await setupWithApi(); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + + await expect( + service.callEndpoint({ + backendId: 'demo', + operationId: 'doesNotExist', + }), + ).rejects.toThrow(/Operation not found/); + expect(apiCalls).toEqual([]); + }); + + it('keeps an explicit Content-Type when the body is a JSON object', async () => { + const captured: Array<{ headers: Headers; body?: string }> = []; + const { service } = await setupWithApi({ + apiHandler: (_input, init) => { + captured.push({ + headers: new Headers(init?.headers), + body: typeof init?.body === 'string' ? init.body : undefined, + }); + return Promise.resolve( + new Response('ok', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ); + }, + }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + + await service.callEndpoint({ + backendId: 'demo', + operationId: 'login', + body: { email: 'a@b.com', password: 'x' }, + headers: { 'Content-Type': 'application/xml' }, + }); + + expect(captured[0]?.headers.get('Content-Type')).toBe('application/xml'); + expect(captured[0]?.body).toBe(JSON.stringify({ email: 'a@b.com', password: 'x' })); + }); + + it('sends a string body without forcing application/json', async () => { + const captured: Array<{ headers: Headers; body?: string }> = []; + const { service } = await setupWithApi({ + apiHandler: (_input, init) => { + captured.push({ + headers: new Headers(init?.headers), + body: typeof init?.body === 'string' ? init.body : undefined, + }); + return Promise.resolve(new Response('ok', { status: 200 })); + }, + }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + + await service.callEndpoint({ + backendId: 'demo', + operationId: 'login', + body: 'raw-payload', + }); + + expect(captured[0]?.body).toBe('raw-payload'); + expect(captured[0]?.headers.get('Content-Type')).toBeNull(); + }); + + /** + * Remembers a process.env key so afterEach can restore it. + * + * @param key - Env key + */ + function snapshotEnv(key: string) { + if (!previousEnv.has(key)) { + previousEnv.set(key, process.env[key]); + } + } + + /** + * Builds a service whose fetch serves the sample OpenAPI and API routes. + * + * @param options - Optional API handler and callMaxBodyBytes override + */ + async function setupWithApi( + options: { + apiHandler?: (input: Parameters[0], init?: RequestInit) => Promise; + callMaxBodyBytes?: number; + } = {}, + ) { + const apiCalls: string[] = []; + const document = sampleOpenApi; + + const fetchImpl: typeof fetch = (input, init) => { + const url = requestUrl(input); + + if (url.endsWith('/docs-json')) { + return Promise.resolve(new Response('nope', { status: 404 })); + } + if (url.endsWith('/docs-yaml')) { + return Promise.resolve( + new Response(JSON.stringify(document), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + + apiCalls.push(url); + if (options.apiHandler) { + return options.apiHandler(input, init); + } + return Promise.resolve( + new Response(JSON.stringify({ accessToken: 'tok', id: '1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }; + + const created = await createService({ fetchImpl }); + if (options.callMaxBodyBytes !== undefined) { + (created.service.config as { callMaxBodyBytes: number }).callMaxBodyBytes = options.callMaxBodyBytes; + } + cleanups.push(created.cleanup); + return { service: created.service, apiCalls }; + } +}); diff --git a/tests/call-tools.spec.ts b/tests/call-tools.spec.ts new file mode 100644 index 0000000..1802ee1 --- /dev/null +++ b/tests/call-tools.spec.ts @@ -0,0 +1,112 @@ +/** + * Seam under test: call_endpoint MCP tool registration (opt-in gate). + * + * Covers: + * 1. registerCallToolsIfEnabled skips registration when enableCalls is false + * 2. registerCallToolsIfEnabled registers call_endpoint when enableCalls is true + * 3. Handler delegates to the service and returns JSON / isError payloads + * + * Out of scope: URL builder and service HTTP edge cases (see call-url / call-endpoint specs). + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { registerCallToolsIfEnabled } from '@tools/call.js'; + +import { createService } from './helpers/create-service.js'; +import { createRecordingMcpServer } from './helpers/recording-mcp-server.js'; +import { requestUrl } from './helpers/request-url.js'; +import { sampleOpenApi } from './fixtures/sample-openapi.js'; + +describe('call_endpoint tool registration', () => { + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((fn) => fn())); + }); + + it('does not register call_endpoint when enableCalls is false', async () => { + const { service } = await setup(); + const recording = createRecordingMcpServer(); + + registerCallToolsIfEnabled(recording.server, service, false); + + expect(recording.toolNames()).not.toContain('call_endpoint'); + }); + + it('registers call_endpoint when enableCalls is true', async () => { + const { service } = await setup(); + const recording = createRecordingMcpServer(); + + registerCallToolsIfEnabled(recording.server, service, true); + + expect(recording.toolNames()).toContain('call_endpoint'); + }); + + it('returns the HTTP result payload for a successful call', async () => { + const { service } = await setup(); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + const recording = createRecordingMcpServer(); + registerCallToolsIfEnabled(recording.server, service, true); + + const result = (await recording.call('call_endpoint', { + backendId: 'demo', + operationId: 'login', + body: { email: 'a@b.com', password: 'x' }, + })) as { content: Array<{ text: string }>; isError?: boolean }; + + expect(result.isError).toBeUndefined(); + const payload = JSON.parse(result.content[0].text) as { status: number; method: string }; + expect(payload.status).toBe(200); + expect(payload.method).toBe('POST'); + }); + + it('maps incomplete lookup to isError without calling the backend API', async () => { + const { service, apiCalls } = await setup(); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'demo' }); + const recording = createRecordingMcpServer(); + registerCallToolsIfEnabled(recording.server, service, true); + + const result = (await recording.call('call_endpoint', { + backendId: 'demo', + method: 'GET', + })) as { isError?: boolean; content: Array<{ text: string }> }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/operationId|method and path/i); + expect(apiCalls).toEqual([]); + }); + + /** + * Service + fetch that serves the sample OpenAPI and JSON API responses. + */ + async function setup() { + const apiCalls: string[] = []; + const document = sampleOpenApi; + const fetchImpl: typeof fetch = (input) => { + const url = requestUrl(input); + if (url.endsWith('/docs-json')) { + return Promise.resolve(new Response('nope', { status: 404 })); + } + if (url.endsWith('/docs-yaml')) { + return Promise.resolve( + new Response(JSON.stringify(document), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + apiCalls.push(url); + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }; + + const created = await createService({ fetchImpl }); + cleanups.push(created.cleanup); + return { service: created.service, apiCalls }; + } +}); diff --git a/tests/call-url.spec.ts b/tests/call-url.spec.ts new file mode 100644 index 0000000..40236e9 --- /dev/null +++ b/tests/call-url.spec.ts @@ -0,0 +1,82 @@ +/** + * Seam under test: buildCallUrl (pure URL assembly for call_endpoint). + * + * Covers: + * 1. baseUrl + path when servers are empty (timesheet-like) + * 2. baseUrl + relative servers[0] + path + * 3. Absolute servers on a foreign origin are ignored + * 4. Absolute servers on the same origin contribute their path prefix + * 5. Path param substitution and missing required param errors + * 6. Query string encoding + * + * Out of scope: HTTP fetch; auth headers; MCP tool wiring. + */ + +import { describe, expect, it } from 'vitest'; + +import { buildCallUrl } from '@openapi/call-url.js'; + +describe('buildCallUrl', () => { + it('joins baseUrl and path when servers are empty', () => { + expect( + buildCallUrl({ + baseUrl: 'http://localhost:3000', + servers: [], + pathTemplate: '/v1/auth/login', + }), + ).toBe('http://localhost:3000/v1/auth/login'); + }); + + it('prepends a relative server path prefix', () => { + expect( + buildCallUrl({ + baseUrl: 'http://localhost:3000', + servers: [{ url: '/api/v1' }], + pathTemplate: '/users/{id}', + pathParams: { id: '42' }, + }), + ).toBe('http://localhost:3000/api/v1/users/42'); + }); + + it('ignores absolute servers on a different origin', () => { + expect( + buildCallUrl({ + baseUrl: 'http://localhost:3000', + servers: [{ url: 'https://api.other.example/v1' }], + pathTemplate: '/v1/users', + }), + ).toBe('http://localhost:3000/v1/users'); + }); + + it('uses path prefix from absolute servers on the same origin', () => { + expect( + buildCallUrl({ + baseUrl: 'http://localhost:3000', + servers: [{ url: 'http://localhost:3000/api' }], + pathTemplate: '/users', + }), + ).toBe('http://localhost:3000/api/users'); + }); + + it('encodes query parameters', () => { + expect( + buildCallUrl({ + baseUrl: 'http://localhost:3000', + servers: [], + pathTemplate: '/v1/entries/history', + query: { from: '2026-01-01', q: 'a b' }, + }), + ).toBe('http://localhost:3000/v1/entries/history?from=2026-01-01&q=a+b'); + }); + + it('throws when a path template parameter is missing', () => { + expect(() => + buildCallUrl({ + baseUrl: 'http://localhost:3000', + servers: [], + pathTemplate: '/v1/users/{id}', + pathParams: {}, + }), + ).toThrow(/path parameter "id"/i); + }); +}); diff --git a/tests/config.spec.ts b/tests/config.spec.ts index ca927da..a1c40b6 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -2,10 +2,13 @@ * Seam under test: loadConfig (env-backed runtime settings). * * Covers: - * 1. Defaults when env vars are unset - * 2. Positive integer overrides for cache/registry TTLs + * 1. Defaults when env vars are unset (including call opt-in off) + * 2. Positive integer overrides for cache/registry TTLs and call limits * 3. Fallback when env values are empty or non-positive * 4. Custom registry path override + * 5. OPENAPI_MCP_ENABLE_CALLS truthy parsing (1 / true / yes, case-insensitive) + * + * Out of scope: MCP tool registration; HTTP execution behavior. */ import os from 'node:os'; @@ -13,10 +16,23 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { DEFAULT_REGISTRY_TTL_MS, DEFAULT_SPEC_CACHE_TTL_MS, loadConfig } from '@/config.js'; +import { + DEFAULT_CALL_MAX_BODY_BYTES, + DEFAULT_CALL_TIMEOUT_MS, + DEFAULT_REGISTRY_TTL_MS, + DEFAULT_SPEC_CACHE_TTL_MS, + loadConfig, +} from '@/config.js'; describe('loadConfig', () => { - const keys = ['OPENAPI_MCP_CACHE_TTL_MS', 'OPENAPI_MCP_REGISTRY_TTL_MS', 'OPENAPI_MCP_REGISTRY_PATH'] as const; + const keys = [ + 'OPENAPI_MCP_CACHE_TTL_MS', + 'OPENAPI_MCP_REGISTRY_TTL_MS', + 'OPENAPI_MCP_REGISTRY_PATH', + 'OPENAPI_MCP_ENABLE_CALLS', + 'OPENAPI_MCP_CALL_TIMEOUT_MS', + 'OPENAPI_MCP_CALL_MAX_BODY_BYTES', + ] as const; const previous = new Map(); afterEach(() => { @@ -39,6 +55,9 @@ describe('loadConfig', () => { expect(config.specCacheTtlMs).toBe(DEFAULT_SPEC_CACHE_TTL_MS); expect(config.registryTtlMs).toBe(DEFAULT_REGISTRY_TTL_MS); expect(config.registryPath).toBe(path.join(os.homedir(), '.openapi-contract-mcp', 'backends.json')); + expect(config.enableCalls).toBe(false); + expect(config.callTimeoutMs).toBe(DEFAULT_CALL_TIMEOUT_MS); + expect(config.callMaxBodyBytes).toBe(DEFAULT_CALL_MAX_BODY_BYTES); }); it('applies positive integer and path overrides from env', () => { @@ -46,12 +65,17 @@ describe('loadConfig', () => { OPENAPI_MCP_CACHE_TTL_MS: '120000', OPENAPI_MCP_REGISTRY_TTL_MS: '3600000', OPENAPI_MCP_REGISTRY_PATH: 'C:\\tmp\\backends.json', + OPENAPI_MCP_CALL_TIMEOUT_MS: '15000', + OPENAPI_MCP_CALL_MAX_BODY_BYTES: '204800', }); expect(loadConfig()).toEqual({ specCacheTtlMs: 120_000, registryTtlMs: 3_600_000, registryPath: 'C:\\tmp\\backends.json', + enableCalls: false, + callTimeoutMs: 15_000, + callMaxBodyBytes: 204_800, }); }); @@ -59,11 +83,15 @@ describe('loadConfig', () => { setEnv({ OPENAPI_MCP_CACHE_TTL_MS: ' ', OPENAPI_MCP_REGISTRY_TTL_MS: '0', + OPENAPI_MCP_CALL_TIMEOUT_MS: '-1', + OPENAPI_MCP_CALL_MAX_BODY_BYTES: 'nope', }); const config = loadConfig(); expect(config.specCacheTtlMs).toBe(DEFAULT_SPEC_CACHE_TTL_MS); expect(config.registryTtlMs).toBe(DEFAULT_REGISTRY_TTL_MS); + expect(config.callTimeoutMs).toBe(DEFAULT_CALL_TIMEOUT_MS); + expect(config.callMaxBodyBytes).toBe(DEFAULT_CALL_MAX_BODY_BYTES); setEnv({ OPENAPI_MCP_REGISTRY_TTL_MS: '-5' }); expect(loadConfig().registryTtlMs).toBe(DEFAULT_REGISTRY_TTL_MS); @@ -72,6 +100,23 @@ describe('loadConfig', () => { expect(loadConfig().registryTtlMs).toBe(DEFAULT_REGISTRY_TTL_MS); }); + it('enables calls for truthy OPENAPI_MCP_ENABLE_CALLS values', () => { + for (const value of ['1', 'true', 'TRUE', 'yes', 'Yes']) { + setEnv({ OPENAPI_MCP_ENABLE_CALLS: value }); + expect(loadConfig().enableCalls).toBe(true); + } + }); + + it('keeps calls disabled for missing or non-truthy ENABLE_CALLS values', () => { + clearEnv(); + expect(loadConfig().enableCalls).toBe(false); + + for (const value of ['0', 'false', 'no', 'on', '']) { + setEnv({ OPENAPI_MCP_ENABLE_CALLS: value }); + expect(loadConfig().enableCalls).toBe(false); + } + }); + /** * Clears known OpenAPI MCP env keys after snapshotting their prior values. */ diff --git a/tests/service.spec.ts b/tests/service.spec.ts index 048ba64..1667acf 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -10,6 +10,9 @@ * 6. refresh / forget / clear lifecycle * 7. listTags, getSecurity, listOperations, searchOperations * 8. Agent-facing errors for missing backend / operation + * 9. Sparse OpenAPI documents use empty overview fallbacks + * 10. getOperation tolerates responses without content and non-object entries + * 11. getSchema reports unresolvable null component schemas via $ref * * Out of scope: BackendRegistry TTL edge cases (see registry.spec), pure OpenAPI * transforms (see openapi-core.spec), MCP tool registration wrappers (see tools.spec). @@ -252,6 +255,92 @@ describe('OpenApiContractService', () => { await expect(service.getSecurity('demo', { operationId: 'nope' })).rejects.toThrow(/list_operations/); }); + it('fills overview fields with empty fallbacks when the document is sparse', async () => { + const sparse = { + openapi: '3.0.0', + paths: {}, + }; + const { service } = await setup({ document: sparse }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'sparse' }); + + const overview = await service.getApiOverview('sparse'); + + expect(overview).toMatchObject({ + info: {}, + openapi: '3.0.0', + servers: [], + tagCount: 0, + operationCount: 0, + schemaCount: 0, + security: [], + securitySchemes: {}, + }); + }); + + it('lists tags when the document omits the tags array entirely', async () => { + const untitled = { + openapi: '3.0.0', + paths: { + '/ping': { + get: { + operationId: 'ping', + tags: ['ops'], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }; + const { service } = await setup({ document: untitled }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'ping' }); + + expect(await service.listTags('ping')).toEqual([{ name: 'ops', description: undefined }]); + }); + + it('keeps non-object response entries and omits content when absent', async () => { + const doc = { + openapi: '3.0.0', + paths: { + '/v1/ping': { + get: { + operationId: 'ping', + responses: { + '204': { description: 'No Content' }, + default: 'unexpected', + }, + }, + }, + }, + }; + const { service } = await setup({ document: doc }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'ping' }); + + const operation = await service.getOperation('ping', { operationId: 'ping' }); + + expect(operation.responses['204']).toEqual({ + description: 'No Content', + content: undefined, + }); + expect(operation.responses.default).toBe('unexpected'); + }); + + it('throws when a $ref resolves to a null component schema', async () => { + const doc = { + openapi: '3.0.0', + paths: {}, + components: { + schemas: { + NullSchema: null, + }, + }, + }; + const { service } = await setup({ document: doc }); + await service.useBackend({ baseUrl: 'http://localhost:3000', id: 'nulls' }); + + await expect(service.getSchema('nulls', '#/components/schemas/NullSchema')).rejects.toThrow( + /Could not resolve schema/, + ); + }); + /** * Creates a service fixture and registers its cleanup for `afterEach`. * From cb0b59ec78d246b5b8a6d1753c80a1ac84d84acd Mon Sep 17 00:00:00 2001 From: Felipe Queis Date: Thu, 23 Jul 2026 23:10:39 -0300 Subject: [PATCH 7/9] Update GitHub Actions workflow to include coverage in tests - Modified the test job in `pr-checks.yml` to run tests with coverage instead of standard tests. - Updated comments to reflect the inclusion of coverage floors from `vitest.config.ts`. --- .github/workflows/pr-checks.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 40997f3..cf71366 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -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). @@ -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 From c7a5e5d9808dbf83d1573a8726d0db23a7af3a6c Mon Sep 17 00:00:00 2001 From: Felipe Queis Date: Thu, 23 Jul 2026 23:10:43 -0300 Subject: [PATCH 8/9] Update documentation to reflect changes in HTTP execution capabilities - Clarified that the server is read-only by default, with optional HTTP execution available when `OPENAPI_MCP_ENABLE_CALLS` is set. - Updated the architecture document to include new responsibilities related to HTTP call helpers. - Enhanced README to provide examples for enabling HTTP calls and detailed environment variable descriptions. - Adjusted sections on tool registration and call limits to align with recent changes in functionality. --- ARCHITECT.md | 52 ++++++++++++++++++++++++++++++++++++++-------------- README.md | 48 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/ARCHITECT.md b/ARCHITECT.md index dfd8a22..fe15e0f 100644 --- a/ARCHITECT.md +++ b/ARCHITECT.md @@ -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) | --- @@ -25,7 +26,7 @@ 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] @@ -33,14 +34,16 @@ flowchart TB 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 ``` @@ -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 +``` --- @@ -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. diff --git a/README.md b/README.md index 3d34095..020e8ea 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) --- @@ -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):** @@ -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 @@ -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. --- @@ -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) | --- @@ -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) | --- From 4f627446042511a7be9a3062831d732bd1720e4e Mon Sep 17 00:00:00 2001 From: Felipe Queis Date: Thu, 23 Jul 2026 23:11:49 -0300 Subject: [PATCH 9/9] Update version to 1.1.0 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 60c1d76..8d280c0 100644 --- a/package.json +++ b/package.json @@ -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",