diff --git a/README.md b/README.md index ed01238..90be98c 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,31 @@ # mcp-from-openapi -> Production-ready TypeScript library for converting OpenAPI specifications into MCP (Model Context Protocol) tool -> definitions +> Convert OpenAPI specifications into MCP tool definitions with automatic parameter conflict resolution [![npm version](https://badge.fury.io/js/mcp-from-openapi.svg)](https://www.npmjs.com/package/mcp-from-openapi) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-yellow.svg)](https://opensource.org/license/apache-2-0) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue.svg)](https://www.typescriptlang.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/) +[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/) ## What This Solves -When converting OpenAPI specs to MCP tools, you encounter **parameter conflicts** - the same parameter name appears in -different locations (path, query, body). This library automatically resolves these conflicts and provides an **explicit -mapper** that tells you exactly how to construct HTTP requests. +When converting OpenAPI specs to MCP tools, you hit **parameter conflicts** -- the same name appears in different locations (path, query, body). This library resolves them automatically and gives you an **explicit mapper** for building HTTP requests. **The Problem:** ```yaml -# OpenAPI spec paths: /users/{id}: post: parameters: - - name: id # id in PATH + - name: id # path in: path requestBody: content: application/json: schema: properties: - id: # id in BODY - CONFLICT! + id: # body -- CONFLICT! type: string ``` @@ -38,8 +35,8 @@ paths: { inputSchema: { properties: { - pathId: { type: "string" }, // Automatically renamed! - bodyId: { type: "string" } // Automatically renamed! + pathId: { type: "string" }, // Automatically renamed + bodyId: { type: "string" } // Automatically renamed } }, mapper: [ @@ -49,17 +46,18 @@ paths: } ``` -Now you know **exactly** how to build the HTTP request! +Now you know exactly how to build the HTTP request. ## Features -- 🎯 **Smart Parameter Handling** - Automatic conflict detection and resolution -- 📦 **Complete Schemas** - Input schema combines all parameters, output schema from responses -- 🔐 **Rich Metadata** - Authentication, servers, tags, deprecation status -- 🔧 **Multiple Input Sources** - Load from URL, file, YAML string, or JSON object -- ✅ **Production Ready** - Full TypeScript, validation, error handling, 80%+ test coverage -- 🧩 **Zod Compatible** - Schemas ready for json-schema-to-zod conversion -- 🚀 **MCP Native** - Designed specifically for Model Context Protocol integration +- **Smart Parameter Handling** -- Automatic conflict detection and resolution across path, query, header, cookie, and body +- **Complete Schemas** -- Input schema combines all parameters; output schema from responses (with oneOf unions) +- **Security Resolution** -- Framework-agnostic auth for Bearer, Basic, Digest, API Key, OAuth2, OpenID, mTLS, HMAC, AWS Sig V4 +- **SSRF Prevention** -- Blocks internal IPs, localhost, and cloud metadata endpoints by default during `$ref` resolution +- **Multiple Input Sources** -- Load from URL, file, YAML string, or JSON object +- **Rich Metadata** -- Authentication, servers, tags, deprecation, external docs, `x-frontmcp` extension +- **Production Ready** -- Full TypeScript support, validation, structured errors, 80%+ test coverage +- **MCP Native** -- Designed specifically for Model Context Protocol integration ## Installation @@ -73,594 +71,102 @@ pnpm add mcp-from-openapi ## Quick Start -### Basic Usage - ```typescript import { OpenAPIToolGenerator } from 'mcp-from-openapi'; -// 1. Load OpenAPI spec +// Load an OpenAPI spec const generator = await OpenAPIToolGenerator.fromURL('https://api.example.com/openapi.json'); -// 2. Generate tools +// Generate MCP tools const tools = await generator.generateTools(); -// 3. Each tool has everything you need: +// Each tool has everything you need tools.forEach((tool) => { - console.log(tool.name); // "createUser" - console.log(tool.inputSchema); // Combined schema for all params - console.log(tool.outputSchema); // Response schema - console.log(tool.mapper); // How to build the HTTP request - console.log(tool.metadata); // Auth, servers, tags, etc. + console.log(tool.name); // "createUser" + console.log(tool.inputSchema); // Combined schema for all params + console.log(tool.outputSchema); // Response schema + console.log(tool.mapper); // How to build the HTTP request + console.log(tool.metadata); // Auth, servers, tags, etc. }); ``` -### Loading from Different Sources - -```typescript -// From URL -const generator = await OpenAPIToolGenerator.fromURL('https://api.example.com/openapi.json'); - -// From file -const generator = await OpenAPIToolGenerator.fromFile('./openapi.yaml'); - -// From YAML string -const yamlString = ` -openapi: 3.0.0 -info: - title: My API - version: 1.0.0 -paths: - /users: - get: - responses: - '200': - description: Success -`; -const generator = await OpenAPIToolGenerator.fromYAML(yamlString); - -// From JSON object -const openApiSpec = { openapi: '3.0.0' /* ... */ }; -const generator = await OpenAPIToolGenerator.fromJSON(openApiSpec); -``` - -### Understanding the Output - -Each generated tool includes: - -```typescript -interface McpOpenAPITool { - name: string; // Operation ID or generated name - description: string; // From operation summary/description - inputSchema: JSONSchema7; // Combined input schema (all params) - outputSchema?: JSONSchema7; // Response schema (can be union) - mapper: ParameterMapper[]; // Input → Request mapping - metadata: ToolMetadata; // Auth, servers, etc. -} -``` - -### Using the Mapper +## Using the Mapper -The mapper tells you how to convert tool input into an HTTP request: +The mapper tells you how to convert tool inputs into an HTTP request: ```typescript -function buildRequest(tool: McpOpenAPITool, input: any) { +function buildRequest(tool: McpOpenAPITool, input: Record) { let path = tool.metadata.path; const query = new URLSearchParams(); const headers: Record = {}; - let body: any; + let body: Record | undefined; - tool.mapper.forEach((m) => { + for (const m of tool.mapper) { const value = input[m.inputKey]; - if (!value) return; + if (value === undefined) continue; switch (m.type) { case 'path': path = path.replace(`{${m.key}}`, encodeURIComponent(value)); break; case 'query': - query.set(m.key, value); + query.set(m.key, String(value)); break; case 'header': - headers[m.key] = value; + headers[m.key] = String(value); break; case 'body': if (!body) body = {}; body[m.key] = value; break; } - }); + } + + const baseUrl = tool.metadata.servers?.[0]?.url ?? ''; + const qs = query.toString(); return { - url: `${tool.metadata.servers[0].url}${path}?${query}`, - method: tool.metadata.method, + url: `${baseUrl}${path}${qs ? '?' + qs : ''}`, + method: tool.metadata.method.toUpperCase(), headers, body: body ? JSON.stringify(body) : undefined, }; } - -// Example usage -const request = buildRequest(tool, { - pathId: 'user-123', - bodyName: 'John Doe', - bodyEmail: 'john@example.com', -}); - -const response = await fetch(request.url, { - method: request.method, - headers: request.headers, - body: request.body, -}); -``` - -### Handling Parameter Conflicts - -When the same parameter name appears in different locations, the library automatically renames them: - -```typescript -// OpenAPI with conflicts: -// - id in path -// - id in query -// - id in body - -const tool = await generator.generateTool('/users/{id}', 'post'); - -// Generated input schema: -{ - properties: { - pathId: { type: "string" }, // Renamed! - queryId: { type: "string" }, // Renamed! - bodyId: { type: "string" } // Renamed! - } -} - -// Your input should use the renamed keys: -const input = { - pathId: "user-123", // Not "id" - queryId: "track-456", - bodyId: "internal-789" -}; -``` - -## Common Use Cases - -### 1. Build an MCP Server - -```typescript -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { OpenAPIToolGenerator } from 'mcp-from-openapi'; - -const server = new Server(/* ... */); - -// Load tools from OpenAPI -const generator = await OpenAPIToolGenerator.fromURL('https://api.example.com/openapi.json'); -const tools = await generator.generateTools(); - -// Register each tool -tools.forEach((tool) => { - server.setRequestHandler(tool.name, async (request) => { - const httpRequest = buildRequest(tool, request.params); - const response = await fetch(httpRequest.url, httpRequest); - return response.json(); - }); -}); -``` - -### 2. Filter Operations - -```typescript -// Only GET operations -const tools = await generator.generateTools({ - filterFn: (op) => op.method === 'get', // op has path and method properties -}); - -// Specific operations by ID -const tools = await generator.generateTools({ - includeOperations: ['getUser', 'createUser'], -}); - -// Exclude deprecated -const tools = await generator.generateTools({ - includeDeprecated: false, -}); -``` - -### 3. Handle Multiple Response Codes - -```typescript -// Include all response status codes -const tools = await generator.generateTools({ - includeAllResponses: true, // Creates oneOf union -}); - -// Or prefer specific codes only -const tools = await generator.generateTools({ - preferredStatusCodes: [200, 201], - includeAllResponses: false, -}); -``` - -### 4. Custom Base URL - -```typescript -const generator = await OpenAPIToolGenerator.fromURL(url, { - baseUrl: 'https://staging.api.example.com', -}); -``` - -### 5. Validate Before Generating - -```typescript -const generator = await OpenAPIToolGenerator.fromFile('./openapi.yaml'); - -const validation = await generator.validate(); -if (!validation.valid) { - console.error('Validation errors:', validation.errors); - throw new Error('Invalid OpenAPI spec'); -} - -const tools = await generator.generateTools(); -``` - -### 6. Custom Naming Strategy - -```typescript -const tool = await generator.generateTool('/users/{id}', 'post', { - namingStrategy: { - conflictResolver: (paramName, location, index) => { - // Custom naming logic - return `${location.toUpperCase()}_${paramName}`; - }, - }, -}); -``` - -### 7. Integration with Zod - -```typescript -import { zodSchema } from 'json-schema-to-zod'; - -const tools = await generator.generateTools(); - -const validatedTools = tools.map((tool) => ({ - ...tool, - validateInput: zodSchema(tool.inputSchema), - validateOutput: tool.outputSchema ? zodSchema(tool.outputSchema) : null, -})); - -// Use validators -const validatedInput = validatedTools[0].validateInput.parse(userInput); -``` - -## API Reference - -### OpenAPIToolGenerator - -#### Static Factory Methods - -```typescript -// Load from URL -static async fromURL(url: string, options?: LoadOptions): Promise - -// Load from file path -static async fromFile(filePath: string, options?: LoadOptions): Promise - -// Load from YAML string -static async fromYAML(yaml: string, options?: LoadOptions): Promise - -// Load from JSON object -static async fromJSON(json: object, options?: LoadOptions): Promise -``` - -#### Instance Methods - -```typescript -// Generate all tools -async generateTools(options?: GenerateOptions): Promise - -// Generate a specific tool -async generateTool(path: string, method: string, options?: GenerateOptions): Promise - -// Get OpenAPI document -getDocument(): OpenAPIDocument - -// Validate OpenAPI document -async validate(): Promise -``` - -### Configuration Options - -#### LoadOptions - -```typescript -interface LoadOptions { - dereference?: boolean; // Resolve $refs (default: true) - baseUrl?: string; // Override base URL - headers?: Record; // Custom headers for URL loading - timeout?: number; // Request timeout (default: 30000ms) - validate?: boolean; // Validate document (default: true) - followRedirects?: boolean; // Follow redirects (default: true) -} -``` - -#### GenerateOptions - -```typescript -interface GenerateOptions { - includeOperations?: string[]; // Include only these operation IDs - excludeOperations?: string[]; // Exclude these operation IDs - filterFn?: (op: OperationWithContext) => boolean; // Custom filter (op has path and method) - namingStrategy?: NamingStrategy; // Custom naming for conflicts - preferredStatusCodes?: number[]; // Preferred response codes - includeDeprecated?: boolean; // Include deprecated ops (default: false) - includeAllResponses?: boolean; // Include all status codes (default: true) - maxSchemaDepth?: number; // Max depth for schemas (default: 10) -} - -// OperationWithContext extends OperationObject with: -interface OperationWithContext extends OperationObject { - path: string; // The API path - method: string; // The HTTP method (get, post, etc.) -} -} -``` - -#### NamingStrategy - -```typescript -interface NamingStrategy { - conflictResolver: (paramName: string, location: ParameterLocation, index: number) => string; - - toolNameGenerator?: (path: string, method: HTTPMethod, operationId?: string) => string; -} -``` - -### Types - -#### McpOpenAPITool - -```typescript -interface McpOpenAPITool { - name: string; - description: string; - inputSchema: JSONSchema7; - outputSchema?: JSONSchema7; - mapper: ParameterMapper[]; - metadata: ToolMetadata; -} -``` - -#### ParameterMapper - -```typescript -interface ParameterMapper { - inputKey: string; // Property name in input schema - type: ParameterLocation; // 'path' | 'query' | 'header' | 'cookie' | 'body' - key: string; // Original parameter name - required?: boolean; - style?: string; - explode?: boolean; - serialization?: SerializationInfo; -} -``` - -#### ToolMetadata - -```typescript -interface ToolMetadata { - path: string; - method: HTTPMethod; - operationId?: string; - tags?: string[]; - deprecated?: boolean; - security?: SecurityRequirement[]; - servers?: ServerInfo[]; - responseStatusCodes?: number[]; - externalDocs?: ExternalDocumentation; -} -``` - -## Error Handling - -```typescript -import { LoadError, ParseError, ValidationError, GenerationError } from 'mcp-from-openapi'; - -try { - const generator = await OpenAPIToolGenerator.fromURL(url); - const tools = await generator.generateTools(); -} catch (error) { - if (error instanceof LoadError) { - console.error('Failed to load:', error.message); - } else if (error instanceof ParseError) { - console.error('Failed to parse:', error.message); - } else if (error instanceof ValidationError) { - console.error('Invalid spec:', error.errors); - } else if (error instanceof GenerationError) { - console.error('Generation failed:', error.message); - } -} -``` - -## Architecture - -### System Overview - -The library follows a modular architecture with clear separation of concerns: - -``` -OpenAPIToolGenerator (Main Entry Point) -├── Validator → OpenAPI document validation -├── ParameterResolver → Parameter conflict resolution & mapping -├── ResponseBuilder → Output schema generation -└── SchemaBuilder → Schema manipulation utilities -``` - -### Data Flow - -``` -User Input (URL/File/String/Object) - ↓ - Load & Parse - ↓ - OpenAPI Document - ↓ - Validate (optional) - ↓ - Dereference $refs (optional) - ↓ - For Each Operation: - ├── ParameterResolver → inputSchema + mapper - ├── ResponseBuilder → outputSchema - └── Metadata Extractor → metadata - ↓ - McpOpenAPITool[] -``` - -### Core Components - -#### 1. OpenAPIToolGenerator - -- **Responsibility**: Entry point, orchestration, document management -- **Key Methods**: Factory methods, generateTools(), validate() - -#### 2. ParameterResolver - -- **Responsibility**: Collect parameters, detect conflicts, generate mapper -- **Algorithm**: - 1. Collect all parameters by name from all sources - 2. Detect naming conflicts - 3. Apply naming strategy to resolve conflicts - 4. Build combined input schema - 5. Create mapper entries - -#### 3. ResponseBuilder - -- **Responsibility**: Extract response schemas, handle multiple status codes -- **Features**: - - Prefer specific status codes - - Generate union types (oneOf) for multiple responses - - Add metadata (status code, content type) - -#### 4. Validator - -- **Responsibility**: Validate OpenAPI document structure -- **Checks**: - - OpenAPI version (3.0.x or 3.1.x) - - Required fields (info, paths, etc.) - - Path parameters defined - - Operation structure - -### Design Patterns - -1. **Factory Pattern** - For creating generator instances -2. **Strategy Pattern** - For parameter naming customization -3. **Builder Pattern** - For schema construction -4. **Template Method** - For tool generation workflow - -### Extension Points - -1. **Custom Naming Strategies** - Implement `NamingStrategy` interface -2. **Custom Filters** - Use `filterFn` in `GenerateOptions` -3. **Custom Validators** - Extend `Validator` class -4. **Schema Transformations** - Use `SchemaBuilder` utilities - -## Best Practices - -### 1. Always Dereference in Production - -```typescript -const generator = await OpenAPIToolGenerator.fromURL(url, { - dereference: true, // Resolve all $refs for easier consumption -}); -``` - -### 2. Validate Before Generating - -```typescript -const validation = await generator.validate(); -if (!validation.valid) { - throw new Error('Invalid OpenAPI spec'); -} ``` -### 3. Cache Generated Tools - -```typescript -class ToolCache { - private cache = new Map(); +## Documentation - async getTools(apiUrl: string): Promise { - if (this.cache has(apiUrl)) { - return this.cache.get(apiUrl)!; - } - - const generator = await OpenAPIToolGenerator.fromURL(apiUrl); - const tools = await generator.generateTools(); - this.cache.set(apiUrl, tools); - - return tools; - } -} -``` - -### 4. Use TypeScript - -```typescript -import type { McpOpenAPITool, LoadOptions } from 'mcp-from-openapi'; - -const options: LoadOptions = { - dereference: true, - validate: true, -}; -``` - -### 5. Handle Errors Properly - -```typescript -try { - const tools = await generator.generateTools(); -} catch (error) { - if (error instanceof ValidationError) { - console.error('Validation errors:', error.errors); - } - // Handle appropriately -} -``` - -## Examples - -Check the `examples/` directory for comprehensive examples including: - -1. Basic usage -2. Parameter conflict resolution -3. Custom naming strategies -4. Multiple response handling -5. Authentication handling -6. Operation filtering -7. Zod integration -8. Request mapping +| Document | Description | +|----------|-------------| +| [Getting Started](docs/getting-started.md) | Loading specs, generating tools, building requests | +| [Configuration](docs/configuration.md) | LoadOptions, GenerateOptions, RefResolutionOptions | +| [Parameter Conflicts](docs/parameter-conflicts.md) | How conflict detection and resolution works | +| [Response Schemas](docs/response-schemas.md) | Output schemas, status codes, oneOf unions | +| [Security](docs/security.md) | SecurityResolver, all auth types, custom resolvers | +| [SSRF Prevention](docs/ssrf-prevention.md) | Ref resolution security, blocked IPs and hosts | +| [Naming Strategies](docs/naming-strategies.md) | Custom tool naming and conflict resolvers | +| [SchemaBuilder](docs/schema-builder.md) | JSON Schema utility methods | +| [Error Handling](docs/error-handling.md) | Error classes, context, and patterns | +| [x-frontmcp Extension](docs/x-frontmcp.md) | Custom OpenAPI extension for MCP annotations | +| [API Reference](docs/api-reference.md) | Complete types, interfaces, and exports | +| [Examples](docs/examples.md) | MCP server, Zod, filtering, security, and more | +| [Architecture](docs/architecture.md) | System overview, data flow, design patterns | ## Requirements - Node.js >= 18.0.0 - TypeScript >= 5.0 (for TypeScript users) - -## Dependencies - -- `@apidevtools/json-schema-ref-parser` - $ref dereferencing -- `yaml` - YAML parsing -- `undici` - Modern HTTP client -- `json-schema` - Type definitions +- Peer dependency: `zod@^4.0.0` ## Contributing -Contributions are welcome! Please see our contributing guidelines. +Contributions are welcome! Please see our [issues page](https://github.com/agentfront/mcp-from-openapi/issues). ## Related Projects - [Model Context Protocol](https://modelcontextprotocol.io/) - [OpenAPI Specification](https://www.openapis.org/) -- [JSON Schema](https://json-schema.org/) ---- +## License -**Made with ❤️ for the MCP community** +[Apache 2.0](LICENSE) diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..ec53a21 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,567 @@ +# API Reference + +[Home](../README.md) | [Getting Started](./getting-started.md) | [Configuration](./configuration.md) | [Examples](./examples.md) + +Complete reference for all exports from `mcp-from-openapi`. + +--- + +## Classes + +### OpenAPIToolGenerator + +Main entry point. Loads OpenAPI specs and generates MCP tool definitions. + +```typescript +import { OpenAPIToolGenerator } from 'mcp-from-openapi'; +``` + +#### Static Factory Methods + +```typescript +static async fromURL(url: string, options?: LoadOptions): Promise +static async fromFile(filePath: string, options?: LoadOptions): Promise +static async fromYAML(yaml: string, options?: LoadOptions): Promise +static async fromJSON(json: object, options?: LoadOptions): Promise +``` + +#### Instance Methods + +```typescript +async generateTools(options?: GenerateOptions): Promise +async generateTool(path: string, method: string, options?: GenerateOptions): Promise +getDocument(): OpenAPIDocument +async validate(): Promise +``` + +--- + +### SecurityResolver + +Framework-agnostic authentication resolver. Maps OpenAPI security schemes to actual auth values (headers, query params, cookies). + +```typescript +import { SecurityResolver } from 'mcp-from-openapi'; +``` + +#### Methods + +```typescript +async resolve(mappers: ParameterMapper[], context: SecurityContext): Promise +async checkMissingSecurity(mappers: ParameterMapper[], context: SecurityContext): Promise +async signRequest(mappers: ParameterMapper[], signatureData: SignatureData, context: SecurityContext): Promise> +``` + +See [Security](./security.md) for detailed usage. + +--- + +### SchemaBuilder + +Static utility class for building and manipulating JSON schemas. + +```typescript +import { SchemaBuilder } from 'mcp-from-openapi'; +``` + +See [SchemaBuilder](./schema-builder.md) for all methods. + +--- + +### ParameterResolver + +Resolves parameters from OpenAPI operations, detects naming conflicts, and generates input schemas with mapper entries. + +```typescript +import { ParameterResolver } from 'mcp-from-openapi'; +``` + +#### Methods + +```typescript +resolve( + operation: OperationObject, + pathParameters?: ParameterObject[], + securityRequirements?: SecurityRequirement[], + includeSecurityInInput?: boolean +): { inputSchema: JsonSchema; mapper: ParameterMapper[] } +``` + +--- + +### ResponseBuilder + +Extracts and combines response schemas from OpenAPI operations. + +```typescript +import { ResponseBuilder } from 'mcp-from-openapi'; +``` + +#### Methods + +```typescript +build(responses?: ResponsesObject): JsonSchema | undefined +``` + +See [Response Schemas](./response-schemas.md) for details. + +--- + +### Validator + +Validates OpenAPI 3.0.x and 3.1.x documents. + +```typescript +import { Validator } from 'mcp-from-openapi'; +``` + +#### Methods + +```typescript +async validate(document: OpenAPIDocument): Promise +``` + +--- + +## Utility Functions + +### createSecurityContext + +Helper to create a `SecurityContext` from partial auth data. + +```typescript +import { createSecurityContext } from 'mcp-from-openapi'; + +const context = createSecurityContext({ + jwt: process.env.JWT_TOKEN, + apiKey: process.env.API_KEY, +}); +``` + +### isReferenceObject + +Type guard to check if an object is a JSON `$ref` reference. + +```typescript +import { isReferenceObject } from 'mcp-from-openapi'; + +if (isReferenceObject(schema)) { + console.log(schema.$ref); +} +``` + +### toJsonSchema + +Converts OpenAPI schema objects to JSON Schema format. Handles OpenAPI 3.0's boolean `exclusiveMinimum`/`exclusiveMaximum` conversion to numeric format. + +```typescript +import { toJsonSchema } from 'mcp-from-openapi'; + +const jsonSchema = toJsonSchema(openApiSchema); +``` + +--- + +## Core Types + +### McpOpenAPITool + +The main output type -- a generated MCP tool definition. + +```typescript +interface McpOpenAPITool { + name: string; // Operation ID or generated name + description: string; // From operation summary/description + inputSchema: JsonSchema; // Combined input schema (all params) + outputSchema?: JsonSchema; // Response schema (can be oneOf union) + mapper: ParameterMapper[]; // Input -> request mapping + metadata: ToolMetadata; // Auth, servers, tags, etc. +} +``` + +> **Note:** `JsonSchema` is the `JSONSchema` type from `zod/v4/core`, not `JSONSchema7`. + +--- + +### ParameterMapper + +Maps input schema properties to their actual HTTP request locations. + +```typescript +interface ParameterMapper { + inputKey: string; // Property name in inputSchema + type: ParameterLocation; // 'path' | 'query' | 'header' | 'cookie' | 'body' + key: string; // Original parameter name + required?: boolean; + style?: string; // 'simple', 'form', 'matrix', etc. + explode?: boolean; // Array/object explosion + serialization?: SerializationInfo; // Content-type, encoding rules + security?: SecurityParameterInfo; // Auth parameter metadata +} +``` + +--- + +### ToolMetadata + +Additional metadata about the generated tool. + +```typescript +interface ToolMetadata { + path: string; // OpenAPI path (e.g., '/users/{id}') + method: HTTPMethod; // HTTP verb + operationId?: string; + operationSummary?: string; // Short description + operationDescription?: string; // Detailed description + tags?: string[]; + deprecated?: boolean; + security?: SecurityRequirement[]; + servers?: ServerInfo[]; + responseStatusCodes?: number[]; // From output schema + externalDocs?: ExternalDocumentationObject; + frontmcp?: FrontMcpExtensionData; // x-frontmcp extension data +} +``` + +--- + +### SecurityParameterInfo + +Security scheme information attached to mapper entries. + +```typescript +interface SecurityParameterInfo { + scheme: string; // Scheme name from OpenAPI (e.g., "BearerAuth") + type: AuthType; // 'apiKey' | 'http' | 'oauth2' | 'openIdConnect' | 'mutualTLS' + httpScheme?: string; // 'bearer', 'basic', 'digest', etc. + bearerFormat?: string; // e.g., 'JWT' + scopes?: string[]; // Required OAuth2 scopes + apiKeyName?: string; // API key parameter name + apiKeyIn?: 'query' | 'header' | 'cookie'; + description?: string; +} +``` + +--- + +### SecurityRequirement + +Security requirement from OpenAPI spec. + +```typescript +interface SecurityRequirement { + scheme: string; + type: AuthType; + scopes?: string[]; + name?: string; // API key parameter name + in?: 'query' | 'header' | 'cookie'; + httpScheme?: string; // 'bearer', 'basic', etc. + bearerFormat?: string; + description?: string; +} +``` + +--- + +### SerializationInfo + +Serialization details for complex parameters. + +```typescript +interface SerializationInfo { + contentType?: string; + encoding?: Record; +} +``` + +--- + +### ServerInfo + +Server information from OpenAPI spec. + +```typescript +interface ServerInfo { + url: string; + description?: string; + variables?: Record; +} +``` + +--- + +### FrontMcpExtensionData + +Custom `x-frontmcp` extension data for MCP-specific configuration. See [x-frontmcp Extension](./x-frontmcp.md). + +```typescript +interface FrontMcpExtensionData { + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; + cache?: { + ttl?: number; + slideWindow?: boolean; + }; + codecall?: { + enabledInCodeCall?: boolean; + visibleInListTools?: boolean; + }; + tags?: string[]; + hideFromDiscovery?: boolean; + examples?: Array<{ + description: string; + input: Record; + output?: unknown; + }>; +} +``` + +--- + +## Security Types + +### SecurityContext + +Auth context provided to `SecurityResolver.resolve()`. + +```typescript +interface SecurityContext { + jwt?: string; // Bearer token + basic?: string; // Base64 "username:password" + digest?: DigestAuthCredentials; // Digest auth credentials + apiKey?: string; // Single API key + apiKeys?: Record; // Multiple API keys by name + oauth2Token?: string; // OAuth2 access token + clientCertificate?: ClientCertificate; + privateKey?: string; // For signature-based auth + publicKey?: string; + hmacSecret?: string; // For HMAC auth + awsCredentials?: AWSCredentials; // AWS Signature V4 + customHeaders?: Record; + cookies?: Record; + customResolver?: (security: SecurityParameterInfo) => string | Promise; + signatureGenerator?: (data: SignatureData, security: SecurityParameterInfo) => string | Promise; +} +``` + +### ResolvedSecurity + +Output from `SecurityResolver.resolve()`. + +```typescript +interface ResolvedSecurity { + headers: Record; + query: Record; + cookies: Record; + clientCertificate?: ClientCertificate; + requiresSignature?: boolean; + signatureInfo?: { + scheme: string; + algorithm?: string; + }; +} +``` + +### DigestAuthCredentials + +```typescript +interface DigestAuthCredentials { + username: string; + password: string; + realm?: string; + nonce?: string; + uri?: string; + qop?: string; + nc?: string; + cnonce?: string; + response?: string; + opaque?: string; +} +``` + +### ClientCertificate + +```typescript +interface ClientCertificate { + cert: string; // PEM format + key: string; // PEM format + passphrase?: string; + ca?: string | string[]; +} +``` + +### AWSCredentials + +```typescript +interface AWSCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + region?: string; + service?: string; +} +``` + +### SignatureData + +```typescript +interface SignatureData { + method: string; + url: string; + headers: Record; + body?: string; + timestamp?: number; +} +``` + +--- + +## Configuration Types + +### LoadOptions + +Options for loading OpenAPI specifications. See [Configuration](./configuration.md). + +```typescript +interface LoadOptions { + dereference?: boolean; // default: true + baseUrl?: string; + headers?: Record; + timeout?: number; // default: 30000 (ms) + validate?: boolean; // default: true + followRedirects?: boolean; // default: true + refResolution?: RefResolutionOptions; +} +``` + +### GenerateOptions + +Options for tool generation. See [Configuration](./configuration.md). + +```typescript +interface GenerateOptions { + includeOperations?: string[]; + excludeOperations?: string[]; + filterFn?: (operation: OperationWithContext) => boolean; + namingStrategy?: NamingStrategy; + preferredStatusCodes?: number[]; // default: [200, 201, 204, 202, 203, 206] + includeDeprecated?: boolean; // default: false + includeAllResponses?: boolean; // default: true + maxSchemaDepth?: number; // default: 10 + includeExamples?: boolean; // default: false + includeSecurityInInput?: boolean; // default: false +} +``` + +### RefResolutionOptions + +Security configuration for `$ref` resolution. See [SSRF Prevention](./ssrf-prevention.md). + +```typescript +interface RefResolutionOptions { + allowedProtocols?: string[]; // default: ['http', 'https'] + allowedHosts?: string[]; + blockedHosts?: string[]; + allowInternalIPs?: boolean; // default: false +} +``` + +### NamingStrategy + +Custom naming for parameter conflict resolution and tool names. See [Naming Strategies](./naming-strategies.md). + +```typescript +interface NamingStrategy { + conflictResolver: (paramName: string, location: ParameterLocation, index: number) => string; + toolNameGenerator?: (path: string, method: HTTPMethod, operationId?: string) => string; +} +``` + +### OperationWithContext + +Operation object extended with path and method context, used in `filterFn`. + +```typescript +type OperationWithContext = OperationObject & { + path: string; + method: string; +}; +``` + +--- + +## Validation Types + +### ValidationResult + +```typescript +interface ValidationResult { + valid: boolean; + errors?: ValidationErrorDetail[]; + warnings?: ValidationWarning[]; +} +``` + +### ValidationErrorDetail + +```typescript +interface ValidationErrorDetail { + message: string; + path?: string; // JSON pointer + code?: string; +} +``` + +### ValidationWarning + +```typescript +interface ValidationWarning { + message: string; + path?: string; + code?: string; +} +``` + +--- + +## Error Classes + +See [Error Handling](./error-handling.md) for usage patterns. + +| Class | Thrown When | +|-------|-----------| +| `OpenAPIToolError` | Base class for all errors | +| `LoadError` | URL fetch or file read fails | +| `ParseError` | YAML/JSON parsing or dereferencing fails | +| `ValidationError` | OpenAPI document is invalid | +| `GenerationError` | Tool generation fails | +| `SchemaError` | Schema manipulation fails | + +--- + +## Basic Types + +```typescript +type OpenAPIVersion = '3.0.0' | '3.0.1' | '3.0.2' | '3.0.3' | '3.1.0'; +type OpenAPIDocument = OpenAPIV3.Document | OpenAPIV3_1.Document; +type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options' | 'trace'; +type ParameterLocation = 'path' | 'query' | 'header' | 'cookie' | 'body'; +type AuthType = 'apiKey' | 'http' | 'oauth2' | 'openIdConnect' | 'mutualTLS'; +``` + +--- + +## Re-exported OpenAPI Types + +These types are re-exported from the `openapi-types` package for convenience: + +`OperationObject`, `ParameterObject`, `RequestBodyObject`, `ResponseObject`, `ResponsesObject`, `MediaTypeObject`, `HeaderObject`, `ExampleObject`, `PathItemObject`, `PathsObject`, `ServerObject`, `SecuritySchemeObject`, `ReferenceObject`, `TagObject`, `ExternalDocumentationObject`, `ServerVariableObject`, `EncodingObject`, `SecurityRequirementObject`, `SchemaObject` + +--- + +**Related:** [Getting Started](./getting-started.md) | [Configuration](./configuration.md) | [Examples](./examples.md) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..eab94cf --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,167 @@ +# Architecture + +[Home](../README.md) | [API Reference](./api-reference.md) + +--- + +## System Overview + +``` +OpenAPIToolGenerator (Main Entry Point) + ├── Validator → OpenAPI document validation + ├── ParameterResolver → Parameter conflict resolution & mapping + ├── ResponseBuilder → Output schema generation + ├── SchemaBuilder → Schema manipulation utilities + └── SecurityResolver → Framework-agnostic auth resolution +``` + +--- + +## Data Flow + +``` +User Input (URL / File / String / Object) + ↓ + Load & Parse + ↓ + OpenAPI Document + ↓ + Validate (optional) + ↓ + Dereference $refs (optional, with SSRF protection) + ↓ + Filter Operations + ↓ + For Each Operation: + ├── ParameterResolver → inputSchema + mapper + ├── ResponseBuilder → outputSchema + └── Metadata Extractor → metadata (security, servers, tags, etc.) + ↓ + McpOpenAPITool[] +``` + +--- + +## Core Components + +### OpenAPIToolGenerator + +**Responsibility:** Entry point, orchestration, document management. + +- Provides four factory methods for loading specs from different sources +- Manages the dereference lifecycle (lazy initialization) +- Coordinates validation, parameter resolution, response building, and metadata extraction +- Handles SSRF protection for `$ref` resolution +- Generates tool names (from operationId or path+method) + +**Key files:** `src/generator.ts` + +### ParameterResolver + +**Responsibility:** Extract parameters, detect conflicts, generate input schema and mapper. + +**Algorithm:** +1. Collect parameters from path item + operation (path, query, header, cookie) +2. Extract request body properties as body parameters +3. Group all parameters by name +4. Detect conflicts (same name in multiple locations) +5. Apply naming strategy to resolve conflicts +6. Build combined `inputSchema` with unique property names +7. Create `ParameterMapper[]` entries mapping inputs to request locations +8. Process security requirements (add to mapper, optionally to schema) + +**Key files:** `src/parameter-resolver.ts` + +### ResponseBuilder + +**Responsibility:** Extract response schemas, handle multiple status codes. + +- Selects content type by preference (JSON > HAL+JSON > problem+JSON > XML > text) +- Handles no-content responses (204) as `type: 'null'` +- Builds `oneOf` unions for multiple responses or selects preferred +- Annotates schemas with `x-status-code` and `x-content-type` +- Falls back through 2xx, 3xx, then first available when preferred codes aren't found + +**Key files:** `src/response-builder.ts` + +### Validator + +**Responsibility:** Validate OpenAPI 3.0.x and 3.1.x document structure. + +**Checks (errors):** +- OpenAPI version field present and valid +- `info`, `info.title`, `info.version` present +- Paths start with `/` +- Path parameters are defined and required +- Operations have responses +- Parameters have name, `in`, and schema/content + +**Checks (warnings):** +- No paths defined +- No servers defined +- No operation IDs +- Security requirements without schemes + +**Key files:** `src/validator.ts` + +### SchemaBuilder + +**Responsibility:** Static utilities for JSON Schema manipulation. + +Provides factory methods (object, array, string, number, etc.), composition (merge, union), modification (withDescription, withRange, etc.), and utilities (clone, flatten, simplify, removeRefs). + +**Key files:** `src/schema-builder.ts` + +### SecurityResolver + +**Responsibility:** Map OpenAPI security schemes to actual auth values. + +- Resolves HTTP (bearer, basic, digest), API Key, OAuth2, OpenID Connect +- Detects signature-based auth (HMAC, AWS, custom) +- Supports custom resolvers for framework-specific auth +- Provides `checkMissingSecurity()` for validation +- Provides `signRequest()` for signature-based auth + +**Key files:** `src/security-resolver.ts` + +--- + +## Design Patterns + +| Pattern | Usage | +|---------|-------| +| **Factory** | `OpenAPIToolGenerator.fromURL()`, `.fromFile()`, `.fromYAML()`, `.fromJSON()` | +| **Strategy** | `NamingStrategy` for parameter conflict resolution and tool naming | +| **Builder** | `SchemaBuilder` for constructing schemas fluently | +| **Template Method** | Tool generation pipeline (validate → dereference → resolve → build → extract) | + +--- + +## Extension Points + +1. **Custom Naming Strategies** -- Implement `NamingStrategy` to control parameter renaming and tool naming +2. **Custom Filters** -- Use `filterFn` in `GenerateOptions` to select which operations become tools +3. **Custom Security Resolution** -- Use `customResolver` in `SecurityContext` for framework-specific auth +4. **Custom Signature Generation** -- Use `signatureGenerator` in `SecurityContext` for signature-based auth +5. **Schema Transformation** -- Use `SchemaBuilder` utilities post-generation + +--- + +## Module Structure + +``` +src/ + ├── index.ts # Public exports + ├── generator.ts # OpenAPIToolGenerator class + ├── parameter-resolver.ts # ParameterResolver class + ├── response-builder.ts # ResponseBuilder class + ├── security-resolver.ts # SecurityResolver class + types + ├── schema-builder.ts # SchemaBuilder class + ├── validator.ts # Validator class + ├── types.ts # Type definitions + utility functions + └── errors.ts # Error class hierarchy +``` + +--- + +**Related:** [API Reference](./api-reference.md) | [Getting Started](./getting-started.md) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..bf4401a --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,151 @@ +# Configuration + +[Home](../README.md) | [Getting Started](./getting-started.md) | [API Reference](./api-reference.md) + +--- + +## LoadOptions + +Passed to factory methods (`fromURL`, `fromFile`, `fromYAML`, `fromJSON`). + +```typescript +const generator = await OpenAPIToolGenerator.fromURL(url, { + dereference: true, + baseUrl: 'https://staging.api.example.com', + headers: { Authorization: 'Bearer token' }, + timeout: 15000, + validate: true, + followRedirects: true, + refResolution: { + allowedProtocols: ['https'], + blockedHosts: ['evil.com'], + }, +}); +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `dereference` | `boolean` | `true` | Resolve all `$ref` pointers in the spec | +| `baseUrl` | `string` | `''` | Override server URLs from the spec | +| `headers` | `Record` | `{}` | Custom HTTP headers for URL loading | +| `timeout` | `number` | `30000` | HTTP request timeout in milliseconds | +| `validate` | `boolean` | `true` | Validate the OpenAPI document on load | +| `followRedirects` | `boolean` | `true` | Follow HTTP redirects when loading from URL | +| `refResolution` | `RefResolutionOptions` | `{}` | Security settings for `$ref` resolution | + +### RefResolutionOptions + +Controls how external `$ref` pointers are resolved during dereferencing. See [SSRF Prevention](./ssrf-prevention.md) for details. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `allowedProtocols` | `string[]` | `['http', 'https']` | Protocols allowed for external refs | +| `allowedHosts` | `string[]` | `[]` (all allowed) | Whitelist specific hostnames | +| `blockedHosts` | `string[]` | `[]` | Additional hostnames to block | +| `allowInternalIPs` | `boolean` | `false` | Disable built-in SSRF protection | + +--- + +## GenerateOptions + +Passed to `generateTools()` and `generateTool()`. + +```typescript +const tools = await generator.generateTools({ + includeOperations: ['getUser', 'createUser'], + excludeOperations: ['deleteUser'], + includeDeprecated: false, + includeAllResponses: true, + preferredStatusCodes: [200, 201], + maxSchemaDepth: 10, + includeExamples: false, + includeSecurityInInput: false, +}); +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `includeOperations` | `string[]` | - | Only include these operation IDs | +| `excludeOperations` | `string[]` | - | Exclude these operation IDs | +| `filterFn` | `(op: OperationWithContext) => boolean` | - | Custom filter function | +| `namingStrategy` | `NamingStrategy` | - | Custom naming for conflicts and tool names | +| `preferredStatusCodes` | `number[]` | `[200, 201, 204, 202, 203, 206]` | Preferred response codes (in order) | +| `includeDeprecated` | `boolean` | `false` | Include deprecated operations | +| `includeAllResponses` | `boolean` | `true` | Include all status codes as oneOf union | +| `maxSchemaDepth` | `number` | `10` | Maximum nesting depth for schemas | +| `includeExamples` | `boolean` | `false` | Include example values in schemas | +| `includeSecurityInInput` | `boolean` | `false` | Add security params to inputSchema | + +### Filtering Operations + +Three ways to filter which operations become tools: + +**By operation ID:** + +```typescript +// Include only specific operations +const tools = await generator.generateTools({ + includeOperations: ['getUser', 'createUser'], +}); + +// Exclude specific operations +const tools = await generator.generateTools({ + excludeOperations: ['deleteUser', 'adminReset'], +}); +``` + +**By custom filter:** + +The `filterFn` receives an `OperationWithContext` -- the OpenAPI operation object extended with `path` and `method` properties: + +```typescript +// Only GET operations +const tools = await generator.generateTools({ + filterFn: (op) => op.method === 'get', +}); + +// Only operations tagged "public" +const tools = await generator.generateTools({ + filterFn: (op) => op.tags?.includes('public') ?? false, +}); + +// Combine: GET operations on /users paths +const tools = await generator.generateTools({ + filterFn: (op) => op.method === 'get' && op.path.startsWith('/users'), +}); +``` + +### includeSecurityInInput + +By default (`false`), security parameters appear **only** in the mapper with a `security` field. Frameworks resolve auth from environment variables, context, or vaults -- not from user input. + +When set to `true`, security parameters are also added to the `inputSchema` as required string properties, allowing callers to pass auth values directly as tool inputs. + +### includeAllResponses + +When `true` (default), the output schema is a `oneOf` union of all response status codes. Each variant includes an `x-status-code` annotation. + +When `false`, only the single preferred status code schema is used (based on `preferredStatusCodes` order). + +--- + +## NamingStrategy + +Customize how parameter conflicts are resolved and how tools are named. See [Naming Strategies](./naming-strategies.md) for details. + +```typescript +const tools = await generator.generateTools({ + namingStrategy: { + conflictResolver: (paramName, location, index) => { + return `${location.toUpperCase()}_${paramName}`; + }, + toolNameGenerator: (path, method, operationId) => { + return operationId ?? `${method}_${path.replace(/\//g, '_')}`; + }, + }, +}); +``` + +--- + +**Related:** [Getting Started](./getting-started.md) | [SSRF Prevention](./ssrf-prevention.md) | [Naming Strategies](./naming-strategies.md) diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..5609db1 --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,167 @@ +# Error Handling + +[Home](../README.md) | [Getting Started](./getting-started.md) | [API Reference](./api-reference.md) + +--- + +## Error Hierarchy + +All errors extend `OpenAPIToolError`, which extends `Error`: + +``` +OpenAPIToolError (base) + ├── LoadError + ├── ParseError + ├── ValidationError + ├── GenerationError + └── SchemaError +``` + +Every error carries a `context` property with structured details about the failure. + +--- + +## Error Classes + +### LoadError + +Thrown when fetching from a URL or reading a file fails. + +```typescript +try { + const generator = await OpenAPIToolGenerator.fromURL('https://example.com/bad-url'); +} catch (error) { + if (error instanceof LoadError) { + console.error(error.message); // "Failed to load OpenAPI spec from URL: ..." + console.error(error.context?.url); // "https://example.com/bad-url" + console.error(error.context?.status); // 404 + } +} +``` + +**Context fields:** `url`, `filePath`, `status`, `originalError` + +### ParseError + +Thrown when YAML/JSON parsing fails or when `$ref` dereferencing fails. + +```typescript +try { + const generator = await OpenAPIToolGenerator.fromYAML('invalid: yaml: : :'); +} catch (error) { + if (error instanceof ParseError) { + console.error(error.message); // "Failed to parse YAML: ..." + } +} +``` + +Also thrown when an OpenAPI document fails validation during load (when `validate: true`): + +```typescript +// ParseError with context.errors containing validation details +``` + +**Context fields:** `originalError`, `errors` (when validation fails during dereference) + +### ValidationError + +Thrown when explicit validation finds spec issues. + +```typescript +try { + const result = await generator.validate(); + if (!result.valid) { + // result.errors: ValidationErrorDetail[] + // result.warnings: ValidationWarning[] + } +} catch (error) { + if (error instanceof ValidationError) { + console.error(error.errors); // Array of validation error details + } +} +``` + +**Context fields:** `errors` (array of validation details) + +### GenerationError + +Thrown when tool generation fails for a specific operation. + +**Context fields:** `path`, `method`, `operationId`, `originalError` + +### SchemaError + +Thrown when schema manipulation fails (e.g., in `SchemaBuilder` operations). + +**Context fields:** `originalError` + +--- + +## Base Class: OpenAPIToolError + +```typescript +class OpenAPIToolError extends Error { + readonly context?: Record; + + constructor(message: string, context?: Record); +} +``` + +All errors include proper stack traces via `Error.captureStackTrace` (Node.js). + +--- + +## Practical Patterns + +### Catch-all with type narrowing + +```typescript +import { + OpenAPIToolGenerator, + LoadError, + ParseError, + ValidationError, + GenerationError, + SchemaError, +} from 'mcp-from-openapi'; + +try { + const generator = await OpenAPIToolGenerator.fromURL(url); + const tools = await generator.generateTools(); +} catch (error) { + if (error instanceof LoadError) { + // Network/file issues -- retry or use fallback + console.error('Load failed:', error.message); + } else if (error instanceof ParseError) { + // Malformed spec -- report to user + console.error('Parse failed:', error.message); + } else if (error instanceof ValidationError) { + // Invalid spec structure -- show details + console.error('Validation failed:', error.errors); + } else if (error instanceof GenerationError) { + // Specific operation failed -- skip or warn + console.error('Generation failed:', error.message); + } else if (error instanceof SchemaError) { + // Schema issue -- unlikely in normal usage + console.error('Schema error:', error.message); + } else { + throw error; // Unknown error + } +} +``` + +### Non-fatal generation errors + +`generateTools()` catches individual operation failures and logs a warning. It will still return tools for operations that succeeded. Use `generateTool()` for strict per-operation error handling: + +```typescript +try { + const tool = await generator.generateTool('/users/{id}', 'get'); +} catch (error) { + // This operation specifically failed +} +``` + +--- + +**Related:** [Getting Started](./getting-started.md) | [API Reference](./api-reference.md) | [Configuration](./configuration.md) diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..ccbe005 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,328 @@ +# Examples + +[Home](../README.md) | [Getting Started](./getting-started.md) | [API Reference](./api-reference.md) + +--- + +## Building an MCP Server + +```typescript +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { OpenAPIToolGenerator } from 'mcp-from-openapi'; + +const generator = await OpenAPIToolGenerator.fromURL('https://api.example.com/openapi.json'); +const tools = await generator.generateTools(); + +const server = new Server(/* config */); + +// Register tools +tools.forEach((tool) => { + server.setRequestHandler(tool.name, async (request) => { + const httpRequest = buildRequest(tool, request.params); + const response = await fetch(httpRequest.url, { + method: httpRequest.method, + headers: { 'Content-Type': 'application/json', ...httpRequest.headers }, + body: httpRequest.body, + }); + return response.json(); + }); +}); + +function buildRequest(tool, input) { + let path = tool.metadata.path; + const query = new URLSearchParams(); + const headers = {}; + let body; + + for (const m of tool.mapper) { + const value = input[m.inputKey]; + if (value === undefined) continue; + + switch (m.type) { + case 'path': + path = path.replace(`{${m.key}}`, encodeURIComponent(value)); + break; + case 'query': + query.set(m.key, String(value)); + break; + case 'header': + headers[m.key] = String(value); + break; + case 'body': + if (!body) body = {}; + body[m.key] = value; + break; + } + } + + const baseUrl = tool.metadata.servers?.[0]?.url ?? ''; + const qs = query.toString(); + + return { + url: `${baseUrl}${path}${qs ? '?' + qs : ''}`, + method: tool.metadata.method.toUpperCase(), + headers, + body: body ? JSON.stringify(body) : undefined, + }; +} +``` + +--- + +## Filtering Operations + +### By HTTP Method + +```typescript +const getTools = await generator.generateTools({ + filterFn: (op) => op.method === 'get', +}); +``` + +### By Tag + +```typescript +const userTools = await generator.generateTools({ + filterFn: (op) => op.tags?.includes('users') ?? false, +}); +``` + +### By Operation ID + +```typescript +const tools = await generator.generateTools({ + includeOperations: ['getUser', 'createUser', 'listUsers'], +}); +``` + +### Exclude Specific Operations + +```typescript +const tools = await generator.generateTools({ + excludeOperations: ['deleteUser', 'adminReset'], +}); +``` + +### Skip Deprecated + +```typescript +const tools = await generator.generateTools({ + includeDeprecated: false, // This is the default +}); +``` + +--- + +## Multiple Response Handling + +### All Responses as Union + +```typescript +const tools = await generator.generateTools({ + includeAllResponses: true, // Default +}); + +// tool.outputSchema = { oneOf: [ +// { ..., "x-status-code": 200 }, +// { ..., "x-status-code": 404 } +// ]} +``` + +### Single Preferred Response + +```typescript +const tools = await generator.generateTools({ + includeAllResponses: false, + preferredStatusCodes: [200, 201], +}); + +// tool.outputSchema = { ..., "x-status-code": 200 } +``` + +--- + +## Custom Naming Strategy + +```typescript +const tools = await generator.generateTools({ + namingStrategy: { + // Custom conflict resolution + conflictResolver: (paramName, location, index) => { + return `${location.toUpperCase()}_${paramName}`; + }, + // Custom tool naming + toolNameGenerator: (path, method, operationId) => { + if (operationId) return operationId; + return `${method}_${path.replace(/[^a-zA-Z0-9]/g, '_')}`; + }, + }, +}); +``` + +--- + +## Security Integration + +### Bearer Token + +```typescript +import { SecurityResolver, createSecurityContext } from 'mcp-from-openapi'; + +const resolver = new SecurityResolver(); +const context = createSecurityContext({ + jwt: process.env.API_TOKEN, +}); + +const resolved = await resolver.resolve(tool.mapper, context); +// resolved.headers = { Authorization: "Bearer ..." } + +const response = await fetch(url, { + headers: { ...resolved.headers }, +}); +``` + +### Multiple Auth Types + +```typescript +const context = createSecurityContext({ + jwt: process.env.JWT_TOKEN, + apiKey: process.env.API_KEY, + oauth2Token: process.env.OAUTH_TOKEN, +}); + +const resolved = await resolver.resolve(tool.mapper, context); +// All relevant auth headers/query/cookies are populated +``` + +### Custom Resolver (Framework Integration) + +```typescript +const context = createSecurityContext({ + customResolver: async (security) => { + // Pull from your framework's auth system + if (security.type === 'http') { + return await authService.getBearerToken(); + } + if (security.type === 'apiKey') { + return await vault.getSecret(security.apiKeyName); + } + return undefined; + }, +}); +``` + +### Check Missing Auth + +```typescript +const missing = await resolver.checkMissingSecurity(tool.mapper, context); +if (missing.length > 0) { + throw new Error(`Missing auth: ${missing.join(', ')}`); +} +``` + +--- + +## Zod Integration + +Use [`json-schema-to-zod`](https://github.com/StefanTerdell/json-schema-to-zod) to convert schemas for runtime validation: + +```typescript +import { jsonSchemaToZod } from 'json-schema-to-zod'; + +const tools = await generator.generateTools(); + +for (const tool of tools) { + const zodSchemaCode = jsonSchemaToZod(tool.inputSchema); + console.log(`Tool: ${tool.name}`); + console.log(`Zod schema: ${zodSchemaCode}`); +} +``` + +--- + +## Validation-First Workflow + +```typescript +const generator = await OpenAPIToolGenerator.fromFile('./openapi.yaml'); + +const result = await generator.validate(); +if (!result.valid) { + console.error('Validation errors:'); + result.errors?.forEach((e) => { + console.error(` ${e.path}: ${e.message} (${e.code})`); + }); + process.exit(1); +} + +if (result.warnings) { + console.warn('Warnings:'); + result.warnings.forEach((w) => { + console.warn(` ${w.path}: ${w.message}`); + }); +} + +const tools = await generator.generateTools(); +``` + +--- + +## SSRF-Safe Loading + +```typescript +// Strict: only HTTPS from trusted hosts +const generator = await OpenAPIToolGenerator.fromURL(untrustedUrl, { + refResolution: { + allowedProtocols: ['https'], + allowedHosts: ['schemas.example.com'], + blockedHosts: ['competitor.com'], + }, +}); + +// No external resolution at all +const generator = await OpenAPIToolGenerator.fromJSON(spec, { + refResolution: { + allowedProtocols: [], + }, +}); +``` + +--- + +## Custom Base URL + +Override server URLs from the spec: + +```typescript +const generator = await OpenAPIToolGenerator.fromURL(url, { + baseUrl: 'https://staging.api.example.com', +}); + +const tools = await generator.generateTools(); +// All tool.metadata.servers[].url will use the staging URL +``` + +--- + +## Caching Generated Tools + +```typescript +class ToolCache { + private cache = new Map(); + + async getTools(apiUrl: string): Promise { + if (this.cache.has(apiUrl)) { + return this.cache.get(apiUrl)!; + } + + const generator = await OpenAPIToolGenerator.fromURL(apiUrl); + const tools = await generator.generateTools(); + this.cache.set(apiUrl, tools); + + return tools; + } +} +``` + +--- + +**Related:** [Getting Started](./getting-started.md) | [Configuration](./configuration.md) | [API Reference](./api-reference.md) diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..2e2b401 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,284 @@ +# Getting Started + +[Home](../README.md) | [Configuration](./configuration.md) | [API Reference](./api-reference.md) + +--- + +## Installation + +```bash +npm install mcp-from-openapi +# or +yarn add mcp-from-openapi +# or +pnpm add mcp-from-openapi +``` + +**Requirements:** +- Node.js >= 18.0.0 +- Peer dependency: `zod@^4.0.0` + +--- + +## Loading an OpenAPI Spec + +The library supports four loading methods: + +### From a URL + +```typescript +import { OpenAPIToolGenerator } from 'mcp-from-openapi'; + +const generator = await OpenAPIToolGenerator.fromURL('https://api.example.com/openapi.json'); +``` + +Supports custom headers, timeout, and redirect control: + +```typescript +const generator = await OpenAPIToolGenerator.fromURL('https://api.example.com/openapi.json', { + headers: { Authorization: 'Bearer token' }, + timeout: 10000, + followRedirects: true, +}); +``` + +Auto-detects JSON vs YAML based on `Content-Type` header or URL extension. + +### From a File + +```typescript +const generator = await OpenAPIToolGenerator.fromFile('./openapi.yaml'); +``` + +Accepts `.json`, `.yaml`, and `.yml` files. For other extensions, attempts JSON first, then YAML. + +### From a YAML String + +```typescript +const generator = await OpenAPIToolGenerator.fromYAML(` +openapi: 3.0.0 +info: + title: My API + version: 1.0.0 +paths: + /users: + get: + operationId: listUsers + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string +`); +``` + +### From a JSON Object + +```typescript +const spec = { + openapi: '3.0.0', + info: { title: 'My API', version: '1.0.0' }, + paths: { + '/users': { + get: { + operationId: 'listUsers', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'array', + items: { type: 'object', properties: { id: { type: 'string' } } }, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +const generator = await OpenAPIToolGenerator.fromJSON(spec); +``` + +The JSON object is deep-cloned internally, so your original is never mutated. + +--- + +## Generating Tools + +### All Tools + +```typescript +const tools = await generator.generateTools(); +``` + +### A Single Tool + +```typescript +const tool = await generator.generateTool('/users/{id}', 'get'); +``` + +### With Options + +```typescript +const tools = await generator.generateTools({ + includeOperations: ['listUsers', 'getUser'], + includeDeprecated: false, + includeAllResponses: true, +}); +``` + +See [Configuration](./configuration.md) for all options. + +--- + +## Understanding the Output + +Each generated tool is an `McpOpenAPITool`: + +```typescript +const tool = tools[0]; + +tool.name; // "listUsers" (from operationId) +tool.description; // "List all users" (from operation summary) +tool.inputSchema; // Combined JSON Schema for all parameters +tool.outputSchema; // Response schema (can be a oneOf union) +tool.mapper; // Array of ParameterMapper entries +tool.metadata; // { path, method, servers, security, tags, ... } +``` + +### The Mapper + +The `mapper` array is the key feature -- it tells you exactly how to convert tool inputs into an HTTP request: + +```typescript +tool.mapper.forEach((m) => { + console.log(`${m.inputKey} -> ${m.type}:${m.key}`); + // e.g., "id -> path:id" + // e.g., "bodyName -> body:name" +}); +``` + +Each mapper entry has: + +| Field | Description | +|-------|-------------| +| `inputKey` | Property name in `inputSchema` | +| `type` | Where to put it: `path`, `query`, `header`, `cookie`, or `body` | +| `key` | The original parameter name | +| `required` | Whether the parameter is required | +| `security` | Present if this is an auth parameter | + +--- + +## Building HTTP Requests from Tools + +Use the mapper to construct the actual HTTP request: + +```typescript +function buildRequest(tool: McpOpenAPITool, input: Record) { + let path = tool.metadata.path; + const query = new URLSearchParams(); + const headers: Record = {}; + let body: Record | undefined; + + for (const m of tool.mapper) { + const value = input[m.inputKey]; + if (value === undefined) continue; + + switch (m.type) { + case 'path': + path = path.replace(`{${m.key}}`, encodeURIComponent(value)); + break; + case 'query': + query.set(m.key, String(value)); + break; + case 'header': + headers[m.key] = String(value); + break; + case 'body': + if (!body) body = {}; + body[m.key] = value; + break; + } + } + + const baseUrl = tool.metadata.servers?.[0]?.url ?? ''; + const queryString = query.toString(); + const url = `${baseUrl}${path}${queryString ? '?' + queryString : ''}`; + + return { + url, + method: tool.metadata.method.toUpperCase(), + headers, + body: body ? JSON.stringify(body) : undefined, + }; +} +``` + +Usage: + +```typescript +const request = buildRequest(tool, { + id: 'user-123', + name: 'John Doe', + email: 'john@example.com', +}); + +const response = await fetch(request.url, { + method: request.method, + headers: { 'Content-Type': 'application/json', ...request.headers }, + body: request.body, +}); +``` + +--- + +## Validation + +Validate an OpenAPI document before generating tools: + +```typescript +const result = await generator.validate(); + +if (!result.valid) { + console.error('Errors:', result.errors); + // [{ message: '...', path: '/paths/...', code: 'MISSING_RESPONSES' }] +} + +if (result.warnings) { + console.warn('Warnings:', result.warnings); + // [{ message: 'No servers defined...', path: '/servers', code: 'NO_SERVERS' }] +} +``` + +By default, validation runs automatically when loading a spec. Disable with `validate: false`: + +```typescript +const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); +``` + +--- + +## Next Steps + +- [Configuration](./configuration.md) -- All LoadOptions and GenerateOptions +- [Parameter Conflicts](./parameter-conflicts.md) -- How automatic conflict resolution works +- [Security](./security.md) -- Authentication handling +- [Examples](./examples.md) -- Complete usage examples + +--- + +**Related:** [Configuration](./configuration.md) | [Parameter Conflicts](./parameter-conflicts.md) | [API Reference](./api-reference.md) diff --git a/docs/naming-strategies.md b/docs/naming-strategies.md new file mode 100644 index 0000000..45bbec4 --- /dev/null +++ b/docs/naming-strategies.md @@ -0,0 +1,122 @@ +# Naming Strategies + +[Home](../README.md) | [Parameter Conflicts](./parameter-conflicts.md) | [Configuration](./configuration.md) + +--- + +## Overview + +The `NamingStrategy` interface controls two things: + +1. **conflictResolver** -- How conflicted parameter names are renamed +2. **toolNameGenerator** -- How tool names are generated (optional) + +--- + +## Default Conflict Resolver + +When no custom strategy is provided, conflicted parameters are prefixed with their location: + +``` +{location}{CapitalizedName} +``` + +| Location | Example Input | Result | +|----------|--------------|--------| +| `path` | `id` | `pathId` | +| `query` | `id` | `queryId` | +| `header` | `id` | `headerId` | +| `cookie` | `id` | `cookieId` | +| `body` | `id` | `bodyId` | + +Only conflicted names are renamed. Unique parameter names are kept as-is. + +--- + +## Custom Conflict Resolver + +```typescript +interface NamingStrategy { + conflictResolver: (paramName: string, location: ParameterLocation, index: number) => string; + toolNameGenerator?: (path: string, method: HTTPMethod, operationId?: string) => string; +} +``` + +### Uppercase prefix + +```typescript +const tools = await generator.generateTools({ + namingStrategy: { + conflictResolver: (paramName, location, index) => { + return `${location.toUpperCase()}_${paramName}`; + }, + }, +}); +// PATH_id, QUERY_id, BODY_id +``` + +### Numbered suffix + +```typescript +const tools = await generator.generateTools({ + namingStrategy: { + conflictResolver: (paramName, location, index) => { + return `${paramName}_${index}`; + }, + }, +}); +// id_0, id_1, id_2 +``` + +### Location abbreviation + +```typescript +const abbrev: Record = { + path: 'p', query: 'q', header: 'h', cookie: 'c', body: 'b', +}; + +const tools = await generator.generateTools({ + namingStrategy: { + conflictResolver: (paramName, location) => { + return `${abbrev[location]}_${paramName}`; + }, + }, +}); +// p_id, q_id, b_id +``` + +--- + +## Custom Tool Name Generator + +By default, tools are named using the operation's `operationId`. If no `operationId` exists, a name is generated from the path and method: + +``` +{method}_{sanitized_path} +``` + +For example: `GET /users/{id}` becomes `get_users_By_id`. + +Override with `toolNameGenerator`: + +```typescript +const tools = await generator.generateTools({ + namingStrategy: { + conflictResolver: (name, loc) => `${loc}${name.charAt(0).toUpperCase()}${name.slice(1)}`, + toolNameGenerator: (path, method, operationId) => { + if (operationId) return operationId; + // camelCase: getUsersById + const parts = path.split('/').filter(Boolean); + const camel = parts + .map((p) => p.replace(/\{(\w+)\}/, 'By$1')) + .map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1))) + .join(''); + return `${method}${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; + }, + }, +}); +``` + +--- + +**Related:** [Parameter Conflicts](./parameter-conflicts.md) | [Configuration](./configuration.md) | [API Reference](./api-reference.md) diff --git a/docs/parameter-conflicts.md b/docs/parameter-conflicts.md new file mode 100644 index 0000000..b5629a2 --- /dev/null +++ b/docs/parameter-conflicts.md @@ -0,0 +1,180 @@ +# Parameter Conflicts + +[Home](../README.md) | [Getting Started](./getting-started.md) | [Naming Strategies](./naming-strategies.md) + +--- + +## The Problem + +OpenAPI operations can have parameters with the same name in different locations. For example, `id` might appear in the path, query, and request body simultaneously: + +```yaml +paths: + /users/{id}: + post: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: id + in: query + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: string +``` + +A flat input schema can't have three properties all named `id`. This library detects these conflicts and resolves them automatically. + +--- + +## How It Works + +1. **Collect** all parameters from path, query, header, cookie, and request body +2. **Group** parameters by name +3. **Detect** names that appear in more than one location +4. **Rename** conflicted parameters using a naming strategy +5. **Build** the input schema with unique property names +6. **Create** mapper entries that map renamed keys back to original names and locations + +--- + +## Default Naming Strategy + +The default conflict resolver prefixes the parameter name with its location: + +``` +{location}{CapitalizedName} +``` + +For the example above, the three `id` parameters become: + +| Original | Location | Resolved Name | +|----------|----------|---------------| +| `id` | path | `pathId` | +| `id` | query | `queryId` | +| `id` | body | `bodyId` | + +The generated output: + +```typescript +const tool = await generator.generateTool('/users/{id}', 'post'); + +// inputSchema.properties: +{ + pathId: { type: "string" }, + queryId: { type: "string" }, + bodyId: { type: "string" } +} + +// mapper: +[ + { inputKey: "pathId", type: "path", key: "id" }, + { inputKey: "queryId", type: "query", key: "id" }, + { inputKey: "bodyId", type: "body", key: "id" } +] +``` + +--- + +## No Conflict = No Rename + +When a parameter name is unique across all locations, it keeps its original name: + +```yaml +parameters: + - name: userId + in: path + - name: limit + in: query +``` + +```typescript +// No conflicts, names unchanged: +// inputSchema.properties: { userId: ..., limit: ... } +// mapper: [ +// { inputKey: "userId", type: "path", key: "userId" }, +// { inputKey: "limit", type: "query", key: "limit" } +// ] +``` + +--- + +## Request Body Parameters + +Request body properties are extracted as individual parameters with `type: 'body'`: + +```yaml +requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + email: + type: string +``` + +Becomes: + +```typescript +// mapper: +[ + { inputKey: "name", type: "body", key: "name", serialization: { contentType: "application/json" } }, + { inputKey: "email", type: "body", key: "email", serialization: { contentType: "application/json" } } +] +``` + +If a body property name conflicts with a path/query parameter, the conflict resolver kicks in (e.g., `bodyName`, `pathName`). + +--- + +## Content Type Selection + +When the request body has multiple content types, the library selects one based on preference: + +1. `application/json` +2. `application/x-www-form-urlencoded` +3. `multipart/form-data` +4. `application/xml` +5. `text/plain` + +Falls back to the first available content type. + +--- + +## Custom Conflict Resolution + +Override the default naming with a custom `conflictResolver`: + +```typescript +const tools = await generator.generateTools({ + namingStrategy: { + conflictResolver: (paramName, location, index) => { + // Uppercase prefix: PATH_id, QUERY_id, BODY_id + return `${location.toUpperCase()}_${paramName}`; + }, + }, +}); +``` + +The resolver receives: +- `paramName` -- the original parameter name +- `location` -- `'path'`, `'query'`, `'header'`, `'cookie'`, or `'body'` +- `index` -- 0-based index among conflicting parameters + +See [Naming Strategies](./naming-strategies.md) for more examples. + +--- + +**Related:** [Naming Strategies](./naming-strategies.md) | [Getting Started](./getting-started.md) | [Configuration](./configuration.md) diff --git a/docs/response-schemas.md b/docs/response-schemas.md new file mode 100644 index 0000000..381ba5d --- /dev/null +++ b/docs/response-schemas.md @@ -0,0 +1,177 @@ +# Response Schemas + +[Home](../README.md) | [Getting Started](./getting-started.md) | [Configuration](./configuration.md) + +--- + +## Overview + +The `ResponseBuilder` extracts response schemas from OpenAPI operations and produces an `outputSchema` for each tool. This schema describes what the API returns. + +--- + +## Single Response + +When an operation has one response, the output schema is that response's schema directly: + +```yaml +responses: + '200': + description: User found + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string +``` + +```typescript +tool.outputSchema; +// { +// type: "object", +// properties: { id: { type: "string" }, name: { type: "string" } }, +// description: "User found", +// "x-status-code": 200, +// "x-content-type": "application/json" +// } +``` + +--- + +## Multiple Responses (oneOf Union) + +When `includeAllResponses: true` (the default), multiple status codes produce a `oneOf` union: + +```yaml +responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + data: + type: string + '404': + description: Not found + content: + application/json: + schema: + type: object + properties: + error: + type: string +``` + +```typescript +tool.outputSchema; +// { +// oneOf: [ +// { type: "object", properties: { data: ... }, "x-status-code": 200 }, +// { type: "object", properties: { error: ... }, "x-status-code": 404 } +// ], +// description: "Response can be one of multiple status codes" +// } +``` + +Each variant is annotated with `x-status-code` so consumers can differentiate. + +--- + +## Single Preferred Response + +Set `includeAllResponses: false` to get only the preferred response: + +```typescript +const tools = await generator.generateTools({ + includeAllResponses: false, +}); +``` + +The preferred status code is selected based on `preferredStatusCodes` order. + +### Selection Priority + +1. **Exact match** in `preferredStatusCodes` (default: `[200, 201, 204, 202, 203, 206]`) +2. **Any 2xx** response +3. **Any 3xx** response +4. **First available** response + +Override the preference order: + +```typescript +const tools = await generator.generateTools({ + includeAllResponses: false, + preferredStatusCodes: [201, 200], // Prefer 201 over 200 +}); +``` + +--- + +## Content Type Selection + +When a response has multiple content types, the builder selects based on preference: + +1. `application/json` +2. `application/hal+json` +3. `application/problem+json` +4. `application/xml` +5. `text/plain` +6. `text/html` + +Falls back to the first available content type. The selected type is annotated as `x-content-type` on the schema. + +--- + +## No Content Responses (204) + +Responses without a `content` field (e.g., 204 No Content) produce a null schema: + +```yaml +responses: + '204': + description: Deleted successfully +``` + +```typescript +// { type: "null", description: "Deleted successfully", "x-status-code": 204 } +``` + +--- + +## Default Response + +The `default` response is used as a fallback only when no other status codes are defined: + +```yaml +responses: + default: + description: Default response + content: + application/json: + schema: + type: object +``` + +If any numbered status code exists, `default` is ignored. + +--- + +## Status Code Metadata + +Response status codes from the output schema are available in metadata: + +```typescript +tool.metadata.responseStatusCodes; +// [200, 404] (from oneOf variants) +// [200] (single response) +``` + +--- + +**Related:** [Configuration](./configuration.md) | [Getting Started](./getting-started.md) | [API Reference](./api-reference.md) diff --git a/docs/schema-builder.md b/docs/schema-builder.md new file mode 100644 index 0000000..c2769c3 --- /dev/null +++ b/docs/schema-builder.md @@ -0,0 +1,211 @@ +# SchemaBuilder + +[Home](../README.md) | [API Reference](./api-reference.md) + +--- + +## Overview + +`SchemaBuilder` is a static utility class for building and manipulating JSON schemas. All methods are pure -- they return new schemas without modifying inputs. + +```typescript +import { SchemaBuilder } from 'mcp-from-openapi'; +``` + +--- + +## Type Constructors + +### object + +```typescript +SchemaBuilder.object( + { name: { type: 'string' }, age: { type: 'integer' } }, + ['name'] // required fields +); +// { type: "object", properties: { name: ..., age: ... }, required: ["name"], additionalProperties: false } +``` + +### array + +```typescript +SchemaBuilder.array({ type: 'string' }, { minItems: 1, maxItems: 100, uniqueItems: true }); +// { type: "array", items: { type: "string" }, minItems: 1, maxItems: 100, uniqueItems: true } +``` + +### string + +```typescript +SchemaBuilder.string({ minLength: 1, maxLength: 255, pattern: '^[a-z]+$', format: 'email', enum: ['a', 'b'] }); +// { type: "string", minLength: 1, maxLength: 255, pattern: "^[a-z]+$", format: "email", enum: ["a", "b"] } +``` + +### number + +```typescript +SchemaBuilder.number({ minimum: 0, maximum: 100, multipleOf: 0.5 }); +// { type: "number", minimum: 0, maximum: 100, multipleOf: 0.5 } +``` + +### integer + +```typescript +SchemaBuilder.integer({ minimum: 1, exclusiveMaximum: 1000 }); +// { type: "integer", minimum: 1, exclusiveMaximum: 1000 } +``` + +### boolean + +```typescript +SchemaBuilder.boolean(); +// { type: "boolean" } +``` + +### null + +```typescript +SchemaBuilder.null(); +// { type: "null" } +``` + +--- + +## Composition + +### merge + +Merges multiple object schemas into one, combining their properties and required fields: + +```typescript +const a = { type: 'object', properties: { x: { type: 'string' } }, required: ['x'] }; +const b = { type: 'object', properties: { y: { type: 'number' } }, required: ['y'] }; + +SchemaBuilder.merge([a, b]); +// { type: "object", properties: { x: ..., y: ... }, required: ["x", "y"] } +``` + +Empty input returns `{ type: "object" }`. Single schema returns it as-is. + +### union + +Creates a `oneOf` union of schemas: + +```typescript +SchemaBuilder.union([ + { type: 'string' }, + { type: 'number' }, +]); +// { oneOf: [{ type: "string" }, { type: "number" }] } +``` + +Single schema returns it as-is (no unnecessary wrapping). + +--- + +## Modification + +These methods add metadata to existing schemas. All return new schemas. + +### withDescription + +```typescript +SchemaBuilder.withDescription(schema, 'A user object'); +``` + +### withExample + +Appends to the schema's `examples` array: + +```typescript +SchemaBuilder.withExample(schema, { name: 'John', age: 30 }); +``` + +### withDefault + +```typescript +SchemaBuilder.withDefault(schema, 'unknown'); +``` + +### withFormat + +```typescript +SchemaBuilder.withFormat(schema, 'date-time'); +``` + +### withPattern + +```typescript +SchemaBuilder.withPattern(schema, '^[A-Z]{2}\\d{4}$'); +``` + +### withEnum + +```typescript +SchemaBuilder.withEnum(schema, ['active', 'inactive', 'pending']); +``` + +### withRange + +```typescript +// Inclusive range +SchemaBuilder.withRange(schema, 0, 100); + +// Exclusive range +SchemaBuilder.withRange(schema, 0, 100, { exclusive: true }); + +// Partial (min only) +SchemaBuilder.withRange(schema, 0); +``` + +### withLength + +```typescript +SchemaBuilder.withLength(schema, 1, 255); // min and max +SchemaBuilder.withLength(schema, 1); // min only +SchemaBuilder.withLength(schema, undefined, 100); // max only +``` + +--- + +## Utilities + +### clone + +Deep clones a schema (JSON serialize/deserialize): + +```typescript +const copy = SchemaBuilder.clone(schema); +``` + +### removeRefs + +Recursively removes all `$ref` properties (use after dereferencing): + +```typescript +const cleaned = SchemaBuilder.removeRefs(schema); +``` + +### flatten + +Flattens nested `oneOf`, `anyOf`, and `allOf` schemas: + +```typescript +// Input: { oneOf: [{ oneOf: [a, b] }, c] } +// Output: { oneOf: [a, b, c] } +SchemaBuilder.flatten(schema); +SchemaBuilder.flatten(schema, 5); // Custom max depth (default: 10) +``` + +### simplify + +Removes empty arrays/objects and deduplicates matching title/description: + +```typescript +SchemaBuilder.simplify(schema); +// Removes: empty required[], empty properties{}, empty examples[] +// Removes: title when it matches description +``` + +--- + +**Related:** [API Reference](./api-reference.md) | [Getting Started](./getting-started.md) diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..1468339 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,255 @@ +# Security + +[Home](../README.md) | [Getting Started](./getting-started.md) | [SSRF Prevention](./ssrf-prevention.md) + +--- + +## Overview + +The `SecurityResolver` class handles OpenAPI security schemes and converts them to actual HTTP auth values. It works with any OpenAPI security scheme name -- your spec can call it `BearerAuth`, `JWT`, `MyCustomAuth`, etc. The resolver uses the scheme's **type** and **metadata**, not its name. + +--- + +## How Security Flows Through the Library + +1. **Generation**: OpenAPI security schemes become `SecurityParameterInfo` entries on mapper items +2. **Resolution**: `SecurityResolver.resolve()` maps a `SecurityContext` to actual headers/query/cookies +3. **Execution**: Your framework applies the resolved values to HTTP requests + +```typescript +import { OpenAPIToolGenerator, SecurityResolver, createSecurityContext } from 'mcp-from-openapi'; + +// 1. Generate tools +const generator = await OpenAPIToolGenerator.fromJSON(spec); +const tools = await generator.generateTools(); + +// 2. Resolve security for a tool +const resolver = new SecurityResolver(); +const context = createSecurityContext({ + jwt: 'eyJhbGciOiJIUzI1NiIs...', + apiKey: 'sk-abc123', +}); + +const resolved = await resolver.resolve(tools[0].mapper, context); + +// 3. Apply to HTTP request +const response = await fetch(url, { + headers: { ...resolved.headers }, + // resolved.query, resolved.cookies also available +}); +``` + +--- + +## Supported Auth Types + +### Bearer / JWT + +```typescript +const context = createSecurityContext({ + jwt: 'eyJhbGciOiJIUzI1NiIs...', +}); +// Produces: { headers: { Authorization: "Bearer eyJhbGci..." } } +``` + +Works with any HTTP bearer scheme, regardless of the OpenAPI scheme name. + +### Basic Auth + +```typescript +const context = createSecurityContext({ + basic: btoa('username:password'), // Base64 encoded +}); +// Produces: { headers: { Authorization: "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" } } +``` + +### Digest Auth + +```typescript +const context = createSecurityContext({ + digest: { + username: 'user', + password: 'pass', + realm: 'example.com', + nonce: 'abc123', + uri: '/api/data', + qop: 'auth', + nc: '00000001', + cnonce: 'xyz789', + response: 'computed-hash', + opaque: 'opaque-value', + }, +}); +// Produces: { headers: { Authorization: "Digest username=\"user\", realm=\"example.com\", ..." } } +``` + +### API Key (Single) + +```typescript +const context = createSecurityContext({ + apiKey: 'sk-abc123', +}); +// Depending on the scheme's "in" field: +// header: { headers: { "X-API-Key": "sk-abc123" } } +// query: { query: { "api_key": "sk-abc123" } } +// cookie: { cookies: { "api_key": "sk-abc123" } } +``` + +### Multiple API Keys + +When your API requires different keys for different purposes: + +```typescript +const context = createSecurityContext({ + apiKeys: { + 'X-API-Key': 'key-for-auth', + 'X-Client-Id': 'client-123', + }, +}); +``` + +Named API keys are matched by the scheme's `apiKeyName` from the OpenAPI spec. + +### OAuth2 / OpenID Connect + +```typescript +const context = createSecurityContext({ + oauth2Token: 'access-token-here', +}); +// Produces: { headers: { Authorization: "Bearer access-token-here" } } +``` + +### Mutual TLS (mTLS) + +```typescript +const context = createSecurityContext({ + clientCertificate: { + cert: '-----BEGIN CERTIFICATE-----\n...', + key: '-----BEGIN PRIVATE KEY-----\n...', + passphrase: 'optional-passphrase', + ca: '-----BEGIN CERTIFICATE-----\n...', + }, +}); +// resolved.clientCertificate is set for your HTTP client to use +``` + +### Custom Headers + +For proprietary auth schemes: + +```typescript +const context = createSecurityContext({ + customHeaders: { + 'X-Custom-Auth': 'custom-value', + 'X-Tenant-Id': 'tenant-123', + }, +}); +``` + +--- + +## Custom Resolver + +For framework-specific auth (e.g., pulling tokens from a session store): + +```typescript +const context = createSecurityContext({ + customResolver: async (security) => { + if (security.type === 'http' && security.httpScheme === 'bearer') { + return await mySessionStore.getToken(); + } + if (security.type === 'apiKey') { + return await myVault.getSecret(security.apiKeyName); + } + return undefined; // Fall through to default resolution + }, +}); +``` + +The custom resolver is tried **first** for every security parameter. Return `undefined` to fall through to built-in resolution. + +--- + +## Signature-Based Auth + +For HMAC, AWS Signature V4, and other signature-based schemes: + +```typescript +const context = createSecurityContext({ + signatureGenerator: async (data, security) => { + // data: { method, url, headers, body, timestamp } + // security: SecurityParameterInfo + const signature = await computeHMAC(data, mySecret); + return signature; + }, +}); + +const resolved = await resolver.resolve(tool.mapper, context); + +if (resolved.requiresSignature) { + const signedHeaders = await resolver.signRequest( + tool.mapper, + { + method: 'GET', + url: 'https://api.example.com/data', + headers: resolved.headers, + }, + context, + ); + // Use signedHeaders in your request +} +``` + +Schemes with names containing `aws4`, `hmac`, `signature`, `hawk`, or `custom-signature` are automatically detected as signature-based. + +--- + +## Checking Missing Auth + +Validate that all required security is available before making requests: + +```typescript +const missing = await resolver.checkMissingSecurity(tool.mapper, context); + +if (missing.length > 0) { + console.error('Missing security schemes:', missing); + // e.g., ["BearerAuth", "ApiKeyAuth"] +} +``` + +--- + +## includeSecurityInInput + +By default, security parameters only appear in the mapper (with a `security` field). Set `includeSecurityInInput: true` to also add them to the `inputSchema`: + +```typescript +const tools = await generator.generateTools({ + includeSecurityInInput: true, +}); + +// Now inputSchema includes security params: +// { properties: { BearerAuth: { type: "string", description: "Bearer authentication token" } } } +``` + +This is useful when callers provide auth values directly (e.g., in testing or when the framework doesn't manage auth). + +--- + +## Identifying Security Mappers + +Security mapper entries have a `security` field: + +```typescript +for (const m of tool.mapper) { + if (m.security) { + console.log(`Auth: ${m.security.scheme} (${m.security.type})`); + console.log(` Header: ${m.key}`); + console.log(` HTTP scheme: ${m.security.httpScheme}`); + } +} +``` + +--- + +**Related:** [SSRF Prevention](./ssrf-prevention.md) | [Configuration](./configuration.md) | [API Reference](./api-reference.md) diff --git a/docs/ssrf-prevention.md b/docs/ssrf-prevention.md new file mode 100644 index 0000000..0114d79 --- /dev/null +++ b/docs/ssrf-prevention.md @@ -0,0 +1,126 @@ +# SSRF Prevention + +[Home](../README.md) | [Configuration](./configuration.md) | [Security](./security.md) + +--- + +## Overview + +When dereferencing `$ref` pointers, the library resolves external URLs. A malicious OpenAPI spec could point `$ref` to internal services, cloud metadata endpoints, or the local filesystem. The library blocks these by default. + +--- + +## Default Protections + +Out of the box, the following are blocked: + +### Blocked Protocols + +- `file://` -- Local filesystem access is blocked +- Only `http://` and `https://` are allowed + +### Blocked Hostnames and IP Ranges + +| Pattern | Description | +|---------|-------------| +| `localhost` | Loopback hostname | +| `127.0.0.0/8` | IPv4 loopback range | +| `10.0.0.0/8` | RFC 1918 private range | +| `172.16.0.0/12` | RFC 1918 private range | +| `192.168.0.0/16` | RFC 1918 private range | +| `169.254.0.0/16` | Link-local / cloud metadata (AWS, GCP) | +| `0.0.0.0` | Unspecified address | +| `metadata.google.internal` | GCP metadata endpoint | +| `::1` | IPv6 loopback | +| `fd00::/8` | IPv6 Unique Local Address | +| `fe80::/10` | IPv6 link-local | + +Bracketed IPv6 forms (`[::1]`, `[fd00:...]`, `[fe80:...]`) are also blocked. + +--- + +## Configuration + +Use `RefResolutionOptions` in `LoadOptions` to customize behavior: + +### Restrict to Specific Hosts + +Only allow refs from trusted hosts: + +```typescript +const generator = await OpenAPIToolGenerator.fromJSON(spec, { + refResolution: { + allowedHosts: ['schemas.example.com', 'api.example.com'], + }, +}); +``` + +When `allowedHosts` is set, **only** these hosts are permitted (in addition to passing the block list check). + +### Add Custom Blocked Hosts + +Block additional hostnames on top of the default list: + +```typescript +const generator = await OpenAPIToolGenerator.fromJSON(spec, { + refResolution: { + blockedHosts: ['untrusted-cdn.com', 'internal.corp.net'], + }, +}); +``` + +### Restrict Protocols + +Allow only HTTPS: + +```typescript +const generator = await OpenAPIToolGenerator.fromJSON(spec, { + refResolution: { + allowedProtocols: ['https'], + }, +}); +``` + +### Disable All External Resolution + +Pass an empty protocols list to block all external `$ref` resolution: + +```typescript +const generator = await OpenAPIToolGenerator.fromJSON(spec, { + refResolution: { + allowedProtocols: [], + }, +}); +``` + +### Allow Internal IPs (Not Recommended) + +Disables the built-in IP block list. Only user-provided `blockedHosts` are checked: + +```typescript +const generator = await OpenAPIToolGenerator.fromJSON(spec, { + refResolution: { + allowInternalIPs: true, // WARNING: SSRF risk + }, +}); +``` + +> **Warning:** Enabling this may expose your application to SSRF attacks against cloud metadata endpoints and internal services. + +--- + +## How It Works + +The library configures `@apidevtools/json-schema-ref-parser` with a custom `canRead` function that: + +1. Parses the `$ref` URL +2. Checks the protocol against `allowedProtocols` +3. Checks the hostname against `allowedHosts` (if configured) +4. Checks the hostname against the blocked list (built-in + `blockedHosts`) +5. Returns `false` (block) if any check fails + +This happens transparently during the dereference step when loading a spec. + +--- + +**Related:** [Security](./security.md) | [Configuration](./configuration.md) | [Getting Started](./getting-started.md) diff --git a/docs/x-frontmcp.md b/docs/x-frontmcp.md new file mode 100644 index 0000000..1044046 --- /dev/null +++ b/docs/x-frontmcp.md @@ -0,0 +1,169 @@ +# x-frontmcp Extension + +[Home](../README.md) | [API Reference](./api-reference.md) | [Configuration](./configuration.md) + +--- + +## Overview + +The `x-frontmcp` extension allows you to add MCP-specific configuration directly in your OpenAPI spec. This data flows through to `tool.metadata.frontmcp` on generated tools. + +--- + +## Usage in OpenAPI + +Add `x-frontmcp` at the operation level: + +```yaml +paths: + /users: + get: + operationId: listUsers + summary: List all users + x-frontmcp: + annotations: + title: User List + readOnlyHint: true + idempotentHint: true + cache: + ttl: 300 + slideWindow: true + tags: + - users + - public + responses: + '200': + description: Success +``` + +--- + +## Fields + +### annotations + +Behavior hints for AI/MCP clients: + +| Field | Type | Description | +|-------|------|-------------| +| `title` | `string` | Display title for the tool | +| `readOnlyHint` | `boolean` | Tool only reads data, never modifies | +| `destructiveHint` | `boolean` | Tool performs destructive actions (delete, overwrite) | +| `idempotentHint` | `boolean` | Calling multiple times has the same effect as once | +| `openWorldHint` | `boolean` | Tool interacts with external systems beyond the API | + +### cache + +Response caching configuration: + +| Field | Type | Description | +|-------|------|-------------| +| `ttl` | `number` | Time-to-live in seconds | +| `slideWindow` | `boolean` | Reset TTL on each access | + +### codecall + +CodeCall integration settings: + +| Field | Type | Description | +|-------|------|-------------| +| `enabledInCodeCall` | `boolean` | Whether the tool is available in CodeCall | +| `visibleInListTools` | `boolean` | Whether the tool appears in tool listings | + +### tags + +Additional tags for categorization (separate from OpenAPI tags): + +```yaml +x-frontmcp: + tags: + - admin + - internal +``` + +### hideFromDiscovery + +When `true`, the tool is not exposed in discovery/listing endpoints: + +```yaml +x-frontmcp: + hideFromDiscovery: true +``` + +### examples + +Usage examples for the tool: + +```yaml +x-frontmcp: + examples: + - description: Get active users + input: + status: active + limit: 10 + output: + users: [{ id: "1", name: "Alice" }] + - description: Get all users + input: {} +``` + +--- + +## Accessing in Code + +```typescript +const tools = await generator.generateTools(); + +for (const tool of tools) { + if (tool.metadata.frontmcp) { + const { annotations, cache, codecall, tags, hideFromDiscovery, examples } = tool.metadata.frontmcp; + + if (annotations?.readOnlyHint) { + // Safe to cache or retry + } + + if (cache?.ttl) { + // Configure response caching + } + + if (hideFromDiscovery) { + // Skip in tool listings + } + } +} +``` + +--- + +## Type Definition + +```typescript +interface FrontMcpExtensionData { + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; + cache?: { + ttl?: number; + slideWindow?: boolean; + }; + codecall?: { + enabledInCodeCall?: boolean; + visibleInListTools?: boolean; + }; + tags?: string[]; + hideFromDiscovery?: boolean; + examples?: Array<{ + description: string; + input: Record; + output?: unknown; + }>; +} +``` + +--- + +**Related:** [API Reference](./api-reference.md) | [Configuration](./configuration.md) | [Getting Started](./getting-started.md)