From dfc352737cb56dc4144ef86da8ae1121fe6ee4ae Mon Sep 17 00:00:00 2001 From: David Antoon Date: Fri, 12 Dec 2025 15:26:32 +0200 Subject: [PATCH 1/4] chore: Remove unused dependency 'openapi-mcp-generator' from package.json --- libs/sdk/package.json | 1 - package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/libs/sdk/package.json b/libs/sdk/package.json index d42ae0b5..be18e629 100644 --- a/libs/sdk/package.json +++ b/libs/sdk/package.json @@ -59,7 +59,6 @@ "axios": "^1.6.0", "ioredis": "^5.8.0", "jose": "^6.1.0", - "openapi-mcp-generator": "^3.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.0", "vectoriadb": "^2.0.1", diff --git a/package.json b/package.json index 14c29365..8889e09f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "axios": "^1.6.0", "ioredis": "^5.8.0", "jose": "^6.1.0", - "openapi-mcp-generator": "^3.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.0", "zod": "^4.0.0", From 924aaf4c1198c55c142e8fba83d23d781cabfd60 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Sat, 13 Dec 2025 05:43:24 +0200 Subject: [PATCH 2/4] feat: Add support for logger in OpenAPI adapter and enhance security validation --- .../openapi-mcp-security-nightmare.mdx | 21 +- docs/draft/docs/adapters/openapi-adapter.mdx | 238 ++++- .../draft/docs/guides/add-openapi-adapter.mdx | 6 +- libs/adapters/src/openapi/README.md | 484 ++++++++- .../src/openapi/__tests__/fixtures.ts | 14 + .../openapi/__tests__/openapi-adapter.spec.ts | 25 +- .../__tests__/openapi-edge-cases.spec.ts | 814 +++++++++++++++ .../openapi-frontmcp-extension.spec.ts | 968 ++++++++++++++++++ .../openapi/__tests__/openapi-loading.spec.ts | 41 +- .../__tests__/openapi-security-unit.spec.ts | 482 +++++++++ .../__tests__/openapi-security.spec.ts | 253 ++++- .../openapi/__tests__/openapi-tools.spec.ts | 155 ++- .../openapi/__tests__/openapi-utils.spec.ts | 17 +- libs/adapters/src/openapi/openapi.adapter.ts | 374 ++++++- .../src/openapi/openapi.frontmcp-schema.ts | 451 ++++++++ libs/adapters/src/openapi/openapi.security.ts | 148 ++- libs/adapters/src/openapi/openapi.tool.ts | 303 +++++- libs/adapters/src/openapi/openapi.types.ts | 543 +++++++++- libs/adapters/src/openapi/openapi.utils.ts | 183 +++- libs/mcp-from-openapi/SECURITY.md | 169 +-- libs/mcp-from-openapi/src/generator.ts | 79 +- libs/mcp-from-openapi/src/index.ts | 1 + libs/mcp-from-openapi/src/types.ts | 68 ++ libs/sdk/src/adapter/adapter.instance.ts | 19 +- .../common/interfaces/adapter.interface.ts | 26 +- yarn.lock | 46 - 26 files changed, 5484 insertions(+), 444 deletions(-) create mode 100644 libs/adapters/src/openapi/__tests__/openapi-edge-cases.spec.ts create mode 100644 libs/adapters/src/openapi/__tests__/openapi-frontmcp-extension.spec.ts create mode 100644 libs/adapters/src/openapi/__tests__/openapi-security-unit.spec.ts create mode 100644 libs/adapters/src/openapi/openapi.frontmcp-schema.ts diff --git a/docs/draft/blog/11-2025/openapi-mcp-security-nightmare.mdx b/docs/draft/blog/11-2025/openapi-mcp-security-nightmare.mdx index e349cbe1..b94df519 100644 --- a/docs/draft/blog/11-2025/openapi-mcp-security-nightmare.mdx +++ b/docs/draft/blog/11-2025/openapi-mcp-security-nightmare.mdx @@ -336,6 +336,20 @@ OpenapiAdapter.init({ You know **immediately** if your security config is wrong. No silent failures in production. +### 4. Defense-in-depth security protections + +Beyond authentication, FrontMCP protects against common attack vectors: + +| Protection | What it prevents | +|------------|------------------| +| **SSRF Prevention** | Blocks dangerous protocols (`file://`, `javascript:`, `data:`) in server URLs | +| **Header Injection** | Rejects control characters (`\r`, `\n`, `\x00`) that could inject headers | +| **Prototype Pollution** | Blocks reserved JS keys (`__proto__`, `constructor`) in input transforms | +| **Integer Overflow** | Content-Length validated with `isFinite()` to prevent size bypass | +| **Query Param Collision** | Detects conflicts between security and user input parameters | + +These protections are automatic—you don't need to configure anything. See [`openapi.executor.ts`](https://github.com/agentfront/frontmcp/blob/main/libs/adapters/src/openapi/openapi.executor.ts) for implementation details. + ### 5. Runs on YOUR infrastructure ```ts @@ -492,7 +506,7 @@ console.log(`Warnings: ${validation.warnings.join('\n')}`); ## The comprehensive test suite -We take security seriously. FrontMCP's OpenAPI adapter has **70 comprehensive tests** covering: +We take security seriously. FrontMCP's OpenAPI adapter has **193 comprehensive tests** covering: - ✅ All authentication strategies - ✅ Request isolation @@ -500,14 +514,15 @@ We take security seriously. FrontMCP's OpenAPI adapter has **70 comprehensive te - ✅ Security validation - ✅ Missing mappings detection - ✅ Headers and body mapping +- ✅ Defense-in-depth protections (SSRF, header injection, prototype pollution) - ✅ Error handling - ✅ Edge cases ```bash npm test -# Test Suites: 6 passed, 6 total -# Tests: 70 passed, 70 total +# Test Suites: 7 passed, 7 total +# Tests: 193 passed, 193 total ``` Every security feature is tested. Every edge case is covered. diff --git a/docs/draft/docs/adapters/openapi-adapter.mdx b/docs/draft/docs/adapters/openapi-adapter.mdx index 8f5aaa0c..4a78503a 100644 --- a/docs/draft/docs/adapters/openapi-adapter.mdx +++ b/docs/draft/docs/adapters/openapi-adapter.mdx @@ -79,8 +79,8 @@ export default class MyApiApp {} Base URL for API requests (e.g., `https://api.example.com/v1`). - - In-memory OpenAPI specification object. Use either `spec` or `url`, not both. + + In-memory OpenAPI specification object. Accepts typed documents or plain objects from JSON imports. Use either `spec` or `url`, not both. @@ -111,6 +111,22 @@ export default class MyApiApp {} Options for tool generation. See [Advanced Features](#advanced-features) for details. + + Hide inputs from the schema and inject values at request time. Supports global, per-tool, and generator-based transforms. See [Input Schema Transforms](#input-schema-transforms). + + + + Customize generated tools with annotations, tags, descriptions, and more. Supports global, per-tool, and generator-based transforms. See [Tool Transforms](#tool-transforms). + + + + How to generate tool descriptions from OpenAPI operations. Default: `'summaryOnly'`. + + + + Logger instance for adapter diagnostics. When using `OpenapiAdapter.init()` within a FrontMCP app, the SDK automatically provides the logger. For standalone usage, you must provide a logger implementing the `FrontMcpLogger` interface. + + ## Authentication The OpenAPI adapter provides multiple authentication strategies with different security risk levels. Choose the approach that best fits your use case. @@ -344,6 +360,206 @@ OpenapiAdapter.init({ }); ``` +### Input Schema Transforms + +Hide inputs from AI/users and inject values server-side at request time. This is more powerful than `inputSchemaMapper` as it provides access to the authentication context. + + + +```ts Global transforms +OpenapiAdapter.init({ + name: 'tenant-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + inputTransforms: { + global: [ + // Hide tenant header from AI, inject from user context + { inputKey: 'X-Tenant-Id', inject: (ctx) => ctx.authInfo.user?.tenantId }, + // Add correlation ID to all requests + { inputKey: 'X-Correlation-Id', inject: () => crypto.randomUUID() }, + ], + }, +}); +``` + +```ts Per-tool transforms +OpenapiAdapter.init({ + name: 'audit-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + inputTransforms: { + perTool: { + 'createAuditLog': [ + { inputKey: 'userId', inject: (ctx) => ctx.authInfo.user?.id }, + { inputKey: 'timestamp', inject: () => new Date().toISOString() }, + ], + }, + }, +}); +``` + +```ts Dynamic generator +OpenapiAdapter.init({ + name: 'my-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + inputTransforms: { + generator: (tool) => { + // Add request ID to all mutating operations + if (['post', 'put', 'patch', 'delete'].includes(tool.metadata.method)) { + return [{ inputKey: 'X-Request-Id', inject: () => crypto.randomUUID() }]; + } + return []; + }, + }, +}); +``` + + + + + **Security Benefit:** Sensitive inputs like tenant IDs and user IDs are injected server-side, never exposed to MCP clients. + + +### Tool Transforms + +Customize generated tools with annotations, tags, descriptions, and more. + + + +```ts Global transforms +OpenapiAdapter.init({ + name: 'my-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + toolTransforms: { + global: { + annotations: { openWorldHint: true }, + }, + }, +}); +``` + +```ts Per-tool transforms +OpenapiAdapter.init({ + name: 'my-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + toolTransforms: { + perTool: { + 'createUser': { + annotations: { destructiveHint: false }, + tags: ['user-management'], + }, + 'deleteUser': { + annotations: { destructiveHint: true }, + tags: ['user-management', 'dangerous'], + }, + }, + }, +}); +``` + +```ts Dynamic generator +OpenapiAdapter.init({ + name: 'my-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + toolTransforms: { + generator: (tool) => { + // Auto-annotate based on HTTP method + if (tool.metadata.method === 'get') { + return { annotations: { readOnlyHint: true, destructiveHint: false } }; + } + if (tool.metadata.method === 'delete') { + return { annotations: { destructiveHint: true } }; + } + return undefined; + }, + }, +}); +``` + + + +**Available transform properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string \| function` | Override or transform the tool name | +| `description` | `string \| function` | Override or transform the tool description | +| `annotations` | `ToolAnnotations` | MCP tool behavior hints | +| `tags` | `string[]` | Categorization tags | +| `examples` | `ToolExample[]` | Usage examples | +| `hideFromDiscovery` | `boolean` | Hide tool from listing | +| `ui` | `ToolUIConfig` | UI configuration for tool forms | + +### x-frontmcp OpenAPI Extension + +Configure tool behavior directly in your OpenAPI spec using the `x-frontmcp` extension: + +```yaml openapi.yaml +paths: + /users: + get: + operationId: listUsers + summary: List all users + x-frontmcp: + annotations: + readOnlyHint: true + idempotentHint: true + cache: + ttl: 300 + tags: + - users + - public-api + delete: + operationId: deleteUser + summary: Delete a user + x-frontmcp: + annotations: + destructiveHint: true + tags: + - users + - dangerous +``` + +**Extension properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `annotations` | `object` | Tool behavior hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title) | +| `cache` | `object` | Cache config: `ttl` (seconds), `slideWindow` (boolean) | +| `codecall` | `object` | CodeCall config: `enabledInCodeCall`, `visibleInListTools` | +| `tags` | `string[]` | Categorization tags | +| `hideFromDiscovery` | `boolean` | Hide from tool listing | +| `examples` | `array` | Usage examples with input/output | + + + Use `x-frontmcp` in your OpenAPI spec for declarative configuration. + Use `toolTransforms` in adapter config to override spec values. + + +### Description Mode + +Control how tool descriptions are generated from OpenAPI operations: + +```ts +OpenapiAdapter.init({ + name: 'my-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + descriptionMode: 'combined', // Default: 'summaryOnly' +}); +``` + +| Mode | Description | +|------|-------------| +| `'summaryOnly'` | Use only the OpenAPI summary (default) | +| `'descriptionOnly'` | Use only the OpenAPI description | +| `'combined'` | Summary followed by description | +| `'full'` | Summary, description, and operation details | + ### Load Options Configure how the OpenAPI spec is loaded. @@ -514,6 +730,24 @@ The adapter automatically validates your security configuration and assigns a ri | **MEDIUM** ⚠️ | `staticAuth`, `additionalHeaders`, or default | Static credentials or default behavior | | **HIGH** 🚨 | `generateOptions.includeSecurityInInput: true` | Auth fields exposed to MCP clients (not recommended) | +## Built-in Security Protections + +Beyond authentication, the adapter includes defense-in-depth protections: + +| Protection | Description | +|------------|-------------| +| **SSRF Prevention** | Validates server URLs, blocks dangerous protocols (`file://`, `javascript://`, `data:`) | +| **Header Injection** | Rejects control characters (`\r`, `\n`, `\x00`, `\f`, `\v`) in header values | +| **Prototype Pollution** | Blocks reserved JS keys (`__proto__`, `constructor`, `prototype`) in input transforms | +| **Request Size Limits** | Content-Length validation with integer overflow protection | +| **Query Param Collision** | Detects conflicts between security and user input parameters | + +**Auth Type Routing:** Tokens are automatically routed to the correct context field based on security scheme type (Bearer → `jwt`, API Key → `apiKey`, Basic → `basic`, OAuth2 → `oauth2Token`). + + + These protections are automatic—no configuration required. See the [README](https://github.com/agentfront/frontmcp/tree/main/libs/adapters/src/openapi#security-protections) for implementation details. + + ## Troubleshooting diff --git a/docs/draft/docs/guides/add-openapi-adapter.mdx b/docs/draft/docs/guides/add-openapi-adapter.mdx index fc2a2aae..24ef01a1 100644 --- a/docs/draft/docs/guides/add-openapi-adapter.mdx +++ b/docs/draft/docs/guides/add-openapi-adapter.mdx @@ -87,7 +87,7 @@ import spec from './openapi.json'; OpenapiAdapter.init({ name: 'expense-api', baseUrl: 'https://api.example.com', - spec: spec as any, // Use local spec instead of URL + spec: spec, // Direct JSON import supported }), ], }) @@ -222,6 +222,10 @@ Becomes a tool with this input: ## Advanced Configuration + + For comprehensive documentation on all configuration options including `inputTransforms`, `toolTransforms`, `descriptionMode`, and the `x-frontmcp` OpenAPI extension, see the [full OpenAPI Adapter documentation](/docs/adapters/openapi-adapter). + + ### Filter Operations Only include specific endpoints: diff --git a/libs/adapters/src/openapi/README.md b/libs/adapters/src/openapi/README.md index bf54151d..2d15dc01 100644 --- a/libs/adapters/src/openapi/README.md +++ b/libs/adapters/src/openapi/README.md @@ -17,7 +17,7 @@ FrontMCP tools with full type safety, authentication support, and automatic requ ✅ **Custom Mappers** - Transform headers and body based on session data -✅ **Production Ready** - Comprehensive error handling and validation +✅ **Production Ready** - Comprehensive error handling, validation, and security protections ## Quick Start @@ -41,7 +41,7 @@ import spec from './openapi.json'; const adapter = new OpenapiAdapter({ name: 'my-api', - spec: spec, + spec: spec, // Accepts object, OpenAPIV3.Document, or OpenAPIV3_1.Document baseUrl: 'https://api.example.com', }); ``` @@ -258,6 +258,7 @@ const adapter = new OpenapiAdapter({ - The adapter looks up the scheme name in `authProviderMapper` - It calls the corresponding extractor to get the token from `authInfo.user` - Different tools automatically use different tokens based on their security requirements +- **Note:** Empty string tokens throw a descriptive error. Return `undefined` if no token is available. #### Approach 2: Custom Security Resolver @@ -315,14 +316,81 @@ const adapter = new OpenapiAdapter({ }); ``` +#### Approach 4: Hybrid Authentication (Per-Scheme Control) + +When you need some security schemes to be provided by the user in tool inputs while others are resolved from context (session/headers), use `securitySchemesInInput`: + +```typescript +// OpenAPI spec with multiple security schemes +{ + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "User's OAuth token" + }, + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Server API key" + } + } + }, + "paths": { + "/data": { + "get": { + "security": [ + { "BearerAuth": [], "ApiKeyAuth": [] } + ] + } + } + } +} + +// Adapter configuration +const adapter = new OpenapiAdapter({ + name: 'hybrid-api', + url: 'https://api.example.com/openapi.json', + baseUrl: 'https://api.example.com', + + // Only these schemes appear in tool input schema (user provides) + securitySchemesInInput: ['BearerAuth'], + + // Other schemes (ApiKeyAuth) resolved from context + authProviderMapper: { + ApiKeyAuth: (authInfo) => authInfo.user?.apiKey, + }, +}); +``` + +**How it works:** + +- `securitySchemesInInput: ['BearerAuth']` - Only `BearerAuth` appears in the tool's input schema +- User provides the Bearer token when calling the tool +- `ApiKeyAuth` is automatically resolved from `authProviderMapper` (not visible to user) +- This is useful when: + - Some credentials are user-specific (OAuth tokens) and must be provided per-call + - Other credentials are server-side secrets (API keys) managed by your application + +**Use cases:** + +1. **Multi-tenant with user OAuth**: Server API key for tenant access + user OAuth token for identity +2. **Third-party integrations**: Your API key for rate limiting + user's token for their data +3. **Hybrid auth flows**: Some endpoints need user tokens, others use service accounts + ### Auth Resolution Priority The adapter resolves authentication in this order: 1. **Custom `securityResolver`** (highest priority) - Full control per tool -2. **`authProviderMapper`** - Map security schemes to auth providers -3. **`staticAuth`** - Static credentials -4. **Default** - Uses `ctx.authInfo.token` (lowest priority) +2. **`authProviderMapper`** with `securitySchemesInInput` - Hybrid: some from input, some from context +3. **`authProviderMapper`** - Map security schemes to auth providers +4. **`staticAuth`** - Static credentials +5. **Default** - Uses `ctx.authInfo.token` (lowest priority) + +**Note:** When using `securitySchemesInInput`, only the specified schemes appear in the tool's input schema. All other schemes must have mappings in `authProviderMapper` or will use the default resolution. ## Real-World Examples @@ -448,6 +516,362 @@ const adapter = new OpenapiAdapter({ - `stripe_getCustomers` tool → Uses Stripe key from `authInfo.user.integrations.stripe.apiKey` - Each tool automatically gets the correct authentication! +## Input Schema Transforms + +Hide inputs from the AI/users and inject values at request time. This is useful for tenant headers, correlation IDs, and +other server-side data that shouldn't be exposed to MCP clients. + +### Basic Usage + +```typescript +const adapter = new OpenapiAdapter({ + name: 'my-api', + baseUrl: 'https://api.example.com', + spec: mySpec, + inputTransforms: { + // Global transforms applied to ALL tools + global: [ + { inputKey: 'X-Tenant-Id', inject: (ctx) => ctx.authInfo.user?.tenantId }, + { inputKey: 'X-Correlation-Id', inject: () => crypto.randomUUID() }, + ], + }, +}); +``` + +### Per-Tool Transforms + +```typescript +const adapter = new OpenapiAdapter({ + name: 'my-api', + baseUrl: 'https://api.example.com', + spec: mySpec, + inputTransforms: { + perTool: { + createUser: [{ inputKey: 'createdBy', inject: (ctx) => ctx.authInfo.user?.email }], + updateUser: [{ inputKey: 'modifiedBy', inject: (ctx) => ctx.authInfo.user?.email }], + }, + }, +}); +``` + +### Dynamic Transforms with Generator + +```typescript +const adapter = new OpenapiAdapter({ + name: 'my-api', + baseUrl: 'https://api.example.com', + spec: mySpec, + inputTransforms: { + generator: (tool) => { + // Add correlation ID to all mutating operations + if (['post', 'put', 'patch', 'delete'].includes(tool.metadata.method)) { + return [{ inputKey: 'X-Request-Id', inject: () => crypto.randomUUID() }]; + } + return []; + }, + }, +}); +``` + +### Transform Context + +The `inject` function receives a context object with: + +- `authInfo` - Authentication info from the MCP session +- `env` - Environment variables (`process.env`) +- `tool` - The OpenAPI tool being executed (access metadata, name, etc.) + +## Tool Transforms + +Customize generated tools with annotations, tags, descriptions, and more. Tool transforms can be applied globally, +per-tool, or dynamically using a generator function. + +### Basic Usage + +```typescript +const adapter = new OpenapiAdapter({ + name: 'my-api', + baseUrl: 'https://api.example.com', + spec: mySpec, + toolTransforms: { + // Global transforms applied to ALL tools + global: { + annotations: { openWorldHint: true }, + }, + }, +}); +``` + +### Per-Tool Transforms + +```typescript +const adapter = new OpenapiAdapter({ + name: 'my-api', + baseUrl: 'https://api.example.com', + spec: mySpec, + toolTransforms: { + perTool: { + createUser: { + annotations: { destructiveHint: false }, + tags: ['user-management'], + }, + deleteUser: { + annotations: { destructiveHint: true }, + tags: ['user-management', 'dangerous'], + }, + }, + }, +}); +``` + +### Dynamic Transforms with Generator + +```typescript +const adapter = new OpenapiAdapter({ + name: 'my-api', + baseUrl: 'https://api.example.com', + spec: mySpec, + toolTransforms: { + generator: (tool) => { + // Auto-annotate based on HTTP method + if (tool.metadata.method === 'get') { + return { annotations: { readOnlyHint: true, destructiveHint: false } }; + } + if (tool.metadata.method === 'delete') { + return { annotations: { destructiveHint: true } }; + } + return undefined; + }, + }, +}); +``` + +### Available Transform Properties + +| Property | Type | Description | +| ------------------- | -------------------- | -------------------------------------------- | +| `name` | `string \| function` | Override or transform the tool name | +| `description` | `string \| function` | Override or transform the tool description | +| `annotations` | `ToolAnnotations` | MCP tool behavior hints | +| `tags` | `string[]` | Categorization tags | +| `examples` | `ToolExample[]` | Usage examples | +| `hideFromDiscovery` | `boolean` | Hide tool from listing (can still be called) | +| `ui` | `ToolUIConfig` | UI configuration for tool forms | + +### Tool Annotations + +```typescript +annotations: { + title: 'Human-readable title', + readOnlyHint: true, // Tool doesn't modify state + destructiveHint: false, // Tool doesn't delete data + idempotentHint: true, // Repeated calls have same effect + openWorldHint: true, // Tool interacts with external systems +} +``` + +## Description Mode + +Control how tool descriptions are generated from OpenAPI operations: + +```typescript +const adapter = new OpenapiAdapter({ + name: 'my-api', + baseUrl: 'https://api.example.com', + spec: mySpec, + descriptionMode: 'combined', // Default: 'summaryOnly' +}); +``` + +| Mode | Description | +| ------------------- | ------------------------------------------- | +| `'summaryOnly'` | Use only the OpenAPI summary (default) | +| `'descriptionOnly'` | Use only the OpenAPI description | +| `'combined'` | Summary followed by description | +| `'full'` | Summary, description, and operation details | + +## x-frontmcp OpenAPI Extension + +Configure tool behavior directly in your OpenAPI spec using the `x-frontmcp` extension. This allows API designers to +embed FrontMCP-specific configuration in the spec itself. + +### Basic Example + +```yaml +paths: + /users: + get: + operationId: listUsers + summary: List all users + x-frontmcp: + annotations: + readOnlyHint: true + idempotentHint: true + cache: + ttl: 300 + tags: + - users + - public-api +``` + +### Full Extension Schema + +```yaml +x-frontmcp: + # Tool annotations (AI behavior hints) + annotations: + title: 'Human-readable title' + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: true + + # Cache configuration + cache: + ttl: 300 # Time-to-live in seconds + slideWindow: true # Slide cache window on access + + # CodeCall plugin configuration + codecall: + enabledInCodeCall: true # Allow use via CodeCall + visibleInListTools: true # Show in list_tools when CodeCall active + + # Categorization + tags: + - users + - public-api + + # Hide from tool listing (can still be called directly) + hideFromDiscovery: false + + # Usage examples + examples: + - description: Get all users + input: {} + output: { users: [], total: 0 } +``` + +### Extension Properties + +| Property | Type | Description | +| ------------------- | ---------- | --------------------------------------------------- | +| `annotations` | `object` | Tool behavior hints (readOnlyHint, etc.) | +| `cache` | `object` | Cache config: `ttl` (seconds), `slideWindow` | +| `codecall` | `object` | CodeCall: `enabledInCodeCall`, `visibleInListTools` | +| `tags` | `string[]` | Categorization tags | +| `hideFromDiscovery` | `boolean` | Hide from tool listing | +| `examples` | `array` | Usage examples with input/output | + +### Priority: Spec vs Adapter + +When both `x-frontmcp` (in the OpenAPI spec) and `toolTransforms` (in adapter config) are used: + +1. `x-frontmcp` in OpenAPI spec is applied first (base layer) +2. `toolTransforms` in adapter config overrides/extends spec values + +This allows API designers to set defaults in the spec, while adapter consumers can override as needed. + +### Complete Example + +```yaml +openapi: '3.0.0' +info: + title: User Management API + version: '1.0.0' +paths: + /users: + get: + operationId: listUsers + summary: List all users + description: Returns a paginated list of users with optional filtering + x-frontmcp: + annotations: + title: List Users + readOnlyHint: true + idempotentHint: true + cache: + ttl: 60 + tags: + - users + - public-api + examples: + - description: List all users + input: { limit: 10 } + output: { users: [{ id: '1', name: 'John' }], total: 1 } + post: + operationId: createUser + summary: Create a new user + x-frontmcp: + annotations: + destructiveHint: false + idempotentHint: false + tags: + - users + - admin + delete: + operationId: deleteUser + summary: Delete a user + x-frontmcp: + annotations: + destructiveHint: true + tags: + - users + - admin + - dangerous +``` + +## Logger Integration + +The adapter uses logging for diagnostics and security analysis. The logger is handled automatically: + +### Within FrontMCP Apps (Recommended) + +When using the adapter within a FrontMCP app, the SDK automatically injects the logger before `fetch()` is called: + +```typescript +import { App } from '@frontmcp/sdk'; +import { OpenapiAdapter } from '@frontmcp/adapters'; + +@App({ + id: 'my-api', + adapters: [ + OpenapiAdapter.init({ + name: 'my-api', + baseUrl: 'https://api.example.com', + spec: mySpec, + // logger is automatically injected by the SDK + }), + ], +}) +export default class MyApiApp {} +``` + +### Standalone Usage + +For standalone usage (outside FrontMCP apps), the adapter automatically creates a console-based logger: + +```typescript +const adapter = new OpenapiAdapter({ + name: 'my-api', + baseUrl: 'https://api.example.com', + spec: mySpec, + // Console logger is created automatically: [openapi:my-api] INFO: ... +}); +``` + +### Custom Logger + +You can provide a custom logger that implements `FrontMcpLogger`: + +```typescript +const adapter = new OpenapiAdapter({ + name: 'my-api', + baseUrl: 'https://api.example.com', + spec: mySpec, + logger: myCustomLogger, // Optional - uses console fallback if not provided +}); +``` + ## How It Works ### 1. Spec Loading & Validation @@ -502,7 +926,14 @@ The adapter automatically builds requests using the parameter mapper: ### 4. Authentication Resolution -Security is resolved automatically using the `SecurityResolver`: +Security is resolved automatically using the `SecurityResolver`. Tokens are routed to the correct context field based on the security scheme type: + +| Scheme Type | Context Field | +| -------------- | --------------------- | +| `http: bearer` | `context.jwt` | +| `apiKey` | `context.apiKey` | +| `http: basic` | `context.basic` | +| `oauth2` | `context.oauth2Token` | ```typescript // 1. Extract security from OpenAPI spec @@ -519,6 +950,20 @@ fetch(url, { }); ``` +## Security Protections + +The adapter includes defense-in-depth security protections: + +| Protection | Description | +| --------------------- | ------------------------------------------------------------------------------------- | +| SSRF Prevention | Validates server URLs, blocks dangerous protocols (`file://`, `javascript:`, `data:`) | +| Header Injection | Rejects control characters (`\r`, `\n`, `\x00`, `\f`, `\v`) in header values | +| Prototype Pollution | Blocks reserved JS keys (`__proto__`, `constructor`, `prototype`) in input transforms | +| Request Size Limits | Content-Length validation with integer overflow protection (`isFinite()` check) | +| Query Param Collision | Detects conflicts between security and user input parameters | + +See [`openapi.executor.ts`](./openapi.executor.ts) for implementation details. + ## Supported Authentication Types | Type | OpenAPI | Auto-Resolved From | @@ -554,11 +999,12 @@ When the adapter loads, it: ### Security Risk Scores -| Score | Configuration | Description | -| ------------- | ------------------------------------------ | --------------------------------------------- | -| **LOW** ✅ | `authProviderMapper` or `securityResolver` | Auth from context - Production ready | -| **MEDIUM** ⚠️ | `staticAuth` or default | Static credentials - Secure but less flexible | -| **HIGH** ❌ | `includeSecurityInInput: true` | User provides auth - High security risk | +| Score | Configuration | Description | +| ------------- | -------------------------------------------------- | --------------------------------------------- | +| **LOW** ✅ | `authProviderMapper` or `securityResolver` | Auth from context - Production ready | +| **MEDIUM** ⚠️ | `securitySchemesInInput` with `authProviderMapper` | Hybrid: some user-provided, some from context | +| **MEDIUM** ⚠️ | `staticAuth` or default | Static credentials - Secure but less flexible | +| **HIGH** ❌ | `includeSecurityInInput: true` | User provides auth - High security risk | ### Example: Missing Auth Configuration @@ -736,12 +1182,28 @@ headersMapper: (authInfo, headers) => { - Ensure security is defined in OpenAPI spec - Verify `ctx.authInfo.token` is available - Add `additionalHeaders` if needed +- Check auth type routing matches your scheme (Bearer → `jwt`, API Key → `apiKey`) ### Type errors - Ensure `dereference: true` to resolve `$ref` objects - Check that JSON schemas are valid +### Empty string token error + +- `authProviderMapper` returned empty string instead of `undefined` +- Return `undefined` or `null` when no token is available + +### Header injection error + +- Header values contain control characters (`\r`, `\n`, `\x00`) +- Sanitize dynamic header values before passing to the adapter + +### Invalid base URL error + +- Server URL from OpenAPI spec failed SSRF validation +- Only `http://` and `https://` protocols are allowed + ## Links - [mcp-from-openapi](https://github.com/frontmcp/mcp-from-openapi) - Core OpenAPI to MCP converter diff --git a/libs/adapters/src/openapi/__tests__/fixtures.ts b/libs/adapters/src/openapi/__tests__/fixtures.ts index 5080adac..0eb437fc 100644 --- a/libs/adapters/src/openapi/__tests__/fixtures.ts +++ b/libs/adapters/src/openapi/__tests__/fixtures.ts @@ -287,3 +287,17 @@ export const spyOnConsole = () => { }, }; }; + +/** + * Mock logger for testing + */ +export const createMockLogger = () => ({ + verbose: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + child: jest.fn().mockReturnThis(), +}); + +export const mockLogger = createMockLogger(); diff --git a/libs/adapters/src/openapi/__tests__/openapi-adapter.spec.ts b/libs/adapters/src/openapi/__tests__/openapi-adapter.spec.ts index 74cfccc6..dc778d5a 100644 --- a/libs/adapters/src/openapi/__tests__/openapi-adapter.spec.ts +++ b/libs/adapters/src/openapi/__tests__/openapi-adapter.spec.ts @@ -3,7 +3,7 @@ */ import OpenapiAdapter from '../openapi.adapter'; -import { basicOpenApiSpec, spyOnConsole } from './fixtures'; +import { basicOpenApiSpec, spyOnConsole, createMockLogger } from './fixtures'; // Mock the OpenAPIToolGenerator jest.mock('mcp-from-openapi', () => ({ @@ -39,6 +39,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), }); expect(adapter).toBeDefined(); @@ -51,6 +52,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { name: 'test-api', baseUrl: 'https://api.example.com', url: 'https://api.example.com/openapi.json', + logger: createMockLogger(), }); expect(adapter).toBeDefined(); @@ -62,6 +64,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), additionalHeaders: { 'X-Custom-Header': 'value', }, @@ -80,6 +83,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), headersMapper, bodyMapper, }); @@ -97,6 +101,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), }); // Generator should not be called in constructor @@ -117,6 +122,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), }); // First fetch - should initialize @@ -142,6 +148,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), generateOptions: { includeDeprecated: true, includeSecurityInInput: true, @@ -156,7 +163,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { includeDeprecated: true, includeSecurityInInput: true, preferredStatusCodes: [200, 201], - }) + }), ); }); @@ -172,6 +179,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), }); await adapter.fetch(); @@ -182,7 +190,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { includeDeprecated: false, includeAllResponses: true, includeSecurityInInput: false, - }) + }), ); }); }); @@ -200,6 +208,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), loadOptions: { validate: false, dereference: false, @@ -214,7 +223,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { baseUrl: 'https://api.example.com', validate: false, dereference: false, - }) + }), ); }); @@ -230,6 +239,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), }); await adapter.fetch(); @@ -239,7 +249,7 @@ describe('OpenapiAdapter - Basic Functionality', () => { expect.objectContaining({ validate: true, dereference: true, - }) + }), ); }); }); @@ -249,11 +259,10 @@ describe('OpenapiAdapter - Basic Functionality', () => { const adapter = new OpenapiAdapter({ name: 'test-api', baseUrl: 'https://api.example.com', + logger: createMockLogger(), } as any); - await expect(adapter.fetch()).rejects.toThrow( - 'Either url or spec must be provided in OpenApiAdapterOptions' - ); + await expect(adapter.fetch()).rejects.toThrow('Either url or spec must be provided in OpenApiAdapterOptions'); }); }); }); diff --git a/libs/adapters/src/openapi/__tests__/openapi-edge-cases.spec.ts b/libs/adapters/src/openapi/__tests__/openapi-edge-cases.spec.ts new file mode 100644 index 00000000..4852784c --- /dev/null +++ b/libs/adapters/src/openapi/__tests__/openapi-edge-cases.spec.ts @@ -0,0 +1,814 @@ +import { buildRequest, parseResponse, validateBaseUrl } from '../openapi.utils'; +import type { McpOpenAPITool } from 'mcp-from-openapi'; + +// Helper to create a basic tool for testing +function createTestTool(overrides: Partial = {}): McpOpenAPITool { + return { + name: 'testTool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + mapper: [], + metadata: { + path: '/test', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + ...overrides, + }; +} + +describe('OpenapiAdapter - Edge Cases', () => { + describe('buildRequest - Type Coercion', () => { + it('should coerce numeric values to strings in query params', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'count', key: 'count', type: 'query', required: true }], + }); + + const result = buildRequest( + tool, + { count: 42 }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + expect(result.url).toBe('https://api.example.com/test?count=42'); + }); + + it('should coerce boolean values to strings in query params', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'active', key: 'active', type: 'query', required: true }], + }); + + const result = buildRequest( + tool, + { active: true }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + expect(result.url).toBe('https://api.example.com/test?active=true'); + }); + + it('should coerce zero to string in path params', () => { + const tool = createTestTool({ + metadata: { path: '/items/{id}', method: 'get', servers: [{ url: 'https://api.example.com' }] }, + mapper: [{ inputKey: 'id', key: 'id', type: 'path', required: true }], + }); + + const result = buildRequest(tool, { id: 0 }, { headers: {}, query: {}, cookies: {} }, 'https://api.example.com'); + expect(result.url).toBe('https://api.example.com/items/0'); + }); + + it('should coerce negative numbers to strings', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'offset', key: 'offset', type: 'query', required: true }], + }); + + const result = buildRequest( + tool, + { offset: -10 }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + expect(result.url).toBe('https://api.example.com/test?offset=-10'); + }); + + it('should coerce float numbers to strings', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'price', key: 'price', type: 'query', required: true }], + }); + + const result = buildRequest( + tool, + { price: 19.99 }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + expect(result.url).toBe('https://api.example.com/test?price=19.99'); + }); + }); + + describe('buildRequest - Header Injection Prevention', () => { + it('should throw error for CRLF injection in header values', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'custom', key: 'X-Custom', type: 'header', required: true }], + }); + + const maliciousValue = 'value\r\nSet-Cookie: admin=true'; + expect(() => { + buildRequest( + tool, + { custom: maliciousValue }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + }).toThrow(/contains control characters/); + }); + + it('should throw error for newline in header values', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'custom', key: 'X-Custom', type: 'header', required: true }], + }); + + const maliciousValue = 'value\nInjected-Header: bad'; + expect(() => { + buildRequest( + tool, + { custom: maliciousValue }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + }).toThrow(/contains control characters/); + }); + + it('should accept valid header values', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'custom', key: 'X-Custom', type: 'header', required: true }], + }); + + const result = buildRequest( + tool, + { custom: 'valid-value-123' }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + expect(result.headers.get('X-Custom')).toBe('valid-value-123'); + }); + }); + + describe('buildRequest - Base URL Normalization', () => { + it('should handle base URL with trailing slash', () => { + const tool = createTestTool({ + metadata: { path: '/users', method: 'get', servers: [] }, + }); + + const result = buildRequest(tool, {}, { headers: {}, query: {}, cookies: {} }, 'https://api.example.com/v1/'); + expect(result.url).toBe('https://api.example.com/v1/users'); + }); + + it('should handle base URL with multiple trailing slashes', () => { + const tool = createTestTool({ + metadata: { path: '/users', method: 'get', servers: [] }, + }); + + const result = buildRequest(tool, {}, { headers: {}, query: {}, cookies: {} }, 'https://api.example.com/v1///'); + expect(result.url).toBe('https://api.example.com/v1/users'); + }); + + it('should handle base URL with port number', () => { + const tool = createTestTool({ + metadata: { path: '/users', method: 'get', servers: [] }, + }); + + const result = buildRequest(tool, {}, { headers: {}, query: {}, cookies: {} }, 'https://api.example.com:8080'); + expect(result.url).toBe('https://api.example.com:8080/users'); + }); + }); + + describe('buildRequest - Array Handling', () => { + it('should handle empty arrays in query params', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'tags', key: 'tags', type: 'query', required: false }], + }); + + const result = buildRequest( + tool, + { tags: [] }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + // Empty array becomes empty string + expect(result.url).toBe('https://api.example.com/test?tags='); + }); + + it('should throw error for arrays in header params', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'custom', key: 'X-Custom', type: 'header', required: true }], + }); + + expect(() => { + buildRequest(tool, { custom: ['a', 'b'] }, { headers: {}, query: {}, cookies: {} }, 'https://api.example.com'); + }).toThrow(/cannot be an array/); + }); + + it('should throw error for arrays in cookie params', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'session', key: 'session', type: 'cookie', required: true }], + }); + + expect(() => { + buildRequest(tool, { session: ['a', 'b'] }, { headers: {}, query: {}, cookies: {} }, 'https://api.example.com'); + }).toThrow(/cannot be an array/); + }); + }); + + describe('buildRequest - Path Parameters', () => { + it('should replace all occurrences of duplicate path parameters', () => { + const tool: McpOpenAPITool = { + name: 'compareUsers', + description: 'Compare users', + inputSchema: { type: 'object', properties: {} }, + mapper: [{ inputKey: 'id', key: 'id', type: 'path', required: true }], + metadata: { + path: '/users/{id}/compare/{id}', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const input = { id: '123' }; + const security = { headers: {}, query: {}, cookies: {} }; + + const result = buildRequest(tool, input, security, 'https://api.example.com'); + + expect(result.url).toBe('https://api.example.com/users/123/compare/123'); + }); + + it('should properly encode special characters in path parameters', () => { + const tool: McpOpenAPITool = { + name: 'getUser', + description: 'Get user', + inputSchema: { type: 'object', properties: {} }, + mapper: [{ inputKey: 'name', key: 'name', type: 'path', required: true }], + metadata: { + path: '/users/{name}', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const input = { name: 'John Doe/Admin' }; + const security = { headers: {}, query: {}, cookies: {} }; + + const result = buildRequest(tool, input, security, 'https://api.example.com'); + + expect(result.url).toBe('https://api.example.com/users/John%20Doe%2FAdmin'); + }); + }); + + describe('buildRequest - Query Parameters', () => { + it('should handle array values in query parameters', () => { + const tool: McpOpenAPITool = { + name: 'searchUsers', + description: 'Search users', + inputSchema: { type: 'object', properties: {} }, + mapper: [{ inputKey: 'tags', key: 'tags', type: 'query', required: false }], + metadata: { + path: '/users', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const input = { tags: ['admin', 'user', 'guest'] }; + const security = { headers: {}, query: {}, cookies: {} }; + + const result = buildRequest(tool, input, security, 'https://api.example.com'); + + expect(result.url).toBe('https://api.example.com/users?tags=admin%2Cuser%2Cguest'); + }); + + it('should throw error for object values in path parameters', () => { + const tool: McpOpenAPITool = { + name: 'getUser', + description: 'Get user', + inputSchema: { type: 'object', properties: {} }, + mapper: [{ inputKey: 'id', key: 'id', type: 'path', required: true }], + metadata: { + path: '/users/{id}', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const input = { id: { nested: 'value' } }; + const security = { headers: {}, query: {}, cookies: {} }; + + expect(() => { + buildRequest(tool, input, security, 'https://api.example.com'); + }).toThrow(/path parameter.*cannot be an object/); + }); + }); + + describe('buildRequest - Cookie Parameters', () => { + it('should properly merge multiple cookies', () => { + const tool: McpOpenAPITool = { + name: 'getData', + description: 'Get data', + inputSchema: { type: 'object', properties: {} }, + mapper: [ + { inputKey: 'session', key: 'session_id', type: 'cookie', required: true }, + { inputKey: 'csrf', key: 'csrf_token', type: 'cookie', required: true }, + ], + metadata: { + path: '/data', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const input = { session: 'abc123', csrf: 'xyz789' }; + const security = { headers: {}, query: {}, cookies: {} }; + + const result = buildRequest(tool, input, security, 'https://api.example.com'); + + expect(result.headers.get('Cookie')).toBe('session_id=abc123; csrf_token=xyz789'); + }); + + it('should throw error for invalid cookie names', () => { + const tool: McpOpenAPITool = { + name: 'getData', + description: 'Get data', + inputSchema: { type: 'object', properties: {} }, + mapper: [{ inputKey: 'data', key: 'invalid name', type: 'cookie', required: true }], + metadata: { + path: '/data', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const input = { data: 'value' }; + const security = { headers: {}, query: {}, cookies: {} }; + + expect(() => { + buildRequest(tool, input, security, 'https://api.example.com'); + }).toThrow(/Invalid cookie name/); + }); + }); + + describe('validateBaseUrl', () => { + it('should accept valid HTTP URLs', () => { + const result = validateBaseUrl('http://api.example.com'); + expect(result.href).toBe('http://api.example.com/'); + }); + + it('should accept valid HTTPS URLs', () => { + const result = validateBaseUrl('https://api.example.com/v1'); + expect(result.href).toBe('https://api.example.com/v1'); + }); + + it('should reject invalid URLs', () => { + expect(() => validateBaseUrl('not-a-url')).toThrow(/Invalid base URL/); + }); + + it('should reject unsupported protocols', () => { + expect(() => validateBaseUrl('ftp://files.example.com')).toThrow(/Unsupported protocol/); + }); + }); + + describe('parseResponse - Response Size', () => { + it('should throw error when response exceeds size limit', async () => { + const largeText = 'x'.repeat(1000); + const response = { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/plain' }), + text: () => Promise.resolve(largeText), + } as Response; + + await expect(parseResponse(response, { maxResponseSize: 100 })).rejects.toThrow(/Response size.*exceeds maximum/); + }); + + it('should accept responses within size limit', async () => { + const text = 'Hello World'; + const response = { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/plain' }), + text: () => Promise.resolve(text), + } as Response; + + const result = await parseResponse(response, { maxResponseSize: 1000 }); + expect(result.data).toBe(text); + }); + }); + + describe('parseResponse - Content-Type Case Sensitivity', () => { + it('should handle uppercase Content-Type', async () => { + const response = { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'APPLICATION/JSON' }), + text: () => Promise.resolve('{"key": "value"}'), + } as Response; + + const result = await parseResponse(response); + expect(result.data).toEqual({ key: 'value' }); + }); + + it('should handle mixed case Content-Type', async () => { + const response = { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'Application/Json; charset=utf-8' }), + text: () => Promise.resolve('{"key": "value"}'), + } as Response; + + const result = await parseResponse(response); + expect(result.data).toEqual({ key: 'value' }); + }); + }); + + describe('parseResponse - Error Messages', () => { + it('should not expose response body or statusText in error messages', async () => { + const sensitiveBody = '{"error": "Secret API key invalid: sk_live_123456"}'; + const response = { + ok: false, + status: 401, + statusText: 'Unauthorized: Invalid API key abc123', // statusText could contain sensitive info + headers: new Headers({ 'content-type': 'application/json' }), + text: () => Promise.resolve(sensitiveBody), + } as Response; + + // Error should only contain status code, not statusText (which could leak secrets) + await expect(parseResponse(response)).rejects.toThrow(/API request failed: 401/); + + try { + await parseResponse(response); + } catch (err) { + const errorMessage = (err as Error).message; + // Ensure error message doesn't contain the sensitive body + expect(errorMessage).not.toContain('sk_live_123456'); + // Ensure error message doesn't contain sensitive statusText + expect(errorMessage).not.toContain('abc123'); + expect(errorMessage).not.toContain('Unauthorized'); + } + }); + }); + + describe('parseResponse - Content-Length Header', () => { + it('should reject response when Content-Length exceeds limit', async () => { + const response = { + ok: true, + status: 200, + headers: new Headers({ + 'content-type': 'application/json', + 'content-length': '100000000', // 100MB + }), + text: () => Promise.resolve('{"data": "test"}'), + } as Response; + + await expect(parseResponse(response, { maxResponseSize: 1000 })).rejects.toThrow( + /Response size.*exceeds maximum/, + ); + }); + + it('should accept response when Content-Length is within limit', async () => { + const response = { + ok: true, + status: 200, + headers: new Headers({ + 'content-type': 'application/json', + 'content-length': '100', + }), + text: () => Promise.resolve('{"data": "test"}'), + } as Response; + + const result = await parseResponse(response, { maxResponseSize: 1000 }); + expect(result.data).toEqual({ data: 'test' }); + }); + }); + + describe('parseResponse - Various HTTP Status Codes', () => { + it('should throw error for 400 Bad Request', async () => { + const response = { + ok: false, + status: 400, + statusText: 'Bad Request', + headers: new Headers({ 'content-type': 'application/json' }), + text: () => Promise.resolve('{"error": "Invalid input"}'), + } as Response; + + await expect(parseResponse(response)).rejects.toThrow(/API request failed: 400/); + }); + + it('should throw error for 403 Forbidden', async () => { + const response = { + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ 'content-type': 'application/json' }), + text: () => Promise.resolve('{"error": "Access denied"}'), + } as Response; + + await expect(parseResponse(response)).rejects.toThrow(/API request failed: 403/); + }); + + it('should throw error for 429 Rate Limited', async () => { + const response = { + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: new Headers({ 'content-type': 'application/json' }), + text: () => Promise.resolve('{"error": "Rate limit exceeded"}'), + } as Response; + + await expect(parseResponse(response)).rejects.toThrow(/API request failed: 429/); + }); + + it('should throw error for 502 Bad Gateway', async () => { + const response = { + ok: false, + status: 502, + statusText: 'Bad Gateway', + headers: new Headers({ 'content-type': 'text/html' }), + text: () => Promise.resolve('Bad Gateway'), + } as Response; + + await expect(parseResponse(response)).rejects.toThrow(/API request failed: 502/); + }); + + it('should throw error for 503 Service Unavailable', async () => { + const response = { + ok: false, + status: 503, + statusText: 'Service Unavailable', + headers: new Headers({ 'content-type': 'text/plain' }), + text: () => Promise.resolve('Service is down'), + } as Response; + + await expect(parseResponse(response)).rejects.toThrow(/API request failed: 503/); + }); + }); + + describe('parseResponse - Empty Response Handling', () => { + it('should handle empty response body', async () => { + const response = { + ok: true, + status: 204, + headers: new Headers({}), + text: () => Promise.resolve(''), + } as Response; + + const result = await parseResponse(response); + expect(result.data).toBe(''); + }); + + it('should handle JSON response with empty object', async () => { + const response = { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + text: () => Promise.resolve('{}'), + } as Response; + + const result = await parseResponse(response); + expect(result.data).toEqual({}); + }); + + it('should handle JSON response with empty array', async () => { + const response = { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + text: () => Promise.resolve('[]'), + } as Response; + + const result = await parseResponse(response); + expect(result.data).toEqual([]); + }); + }); + + describe('validateBaseUrl - Extended', () => { + it('should handle localhost URLs', () => { + const result = validateBaseUrl('http://localhost:3000'); + expect(result.href).toBe('http://localhost:3000/'); + }); + + it('should handle 127.0.0.1 URLs', () => { + const result = validateBaseUrl('http://127.0.0.1:8080/api'); + expect(result.href).toBe('http://127.0.0.1:8080/api'); + }); + + it('should handle URLs with path segments', () => { + const result = validateBaseUrl('https://api.example.com/v1/api'); + expect(result.href).toBe('https://api.example.com/v1/api'); + }); + + it('should reject file:// protocol', () => { + expect(() => validateBaseUrl('file:///etc/passwd')).toThrow(/Unsupported protocol/); + }); + + it('should reject javascript: protocol', () => { + expect(() => validateBaseUrl('javascript:alert(1)')).toThrow(/Unsupported protocol|Invalid base URL/); + }); + + it('should reject data: protocol', () => { + expect(() => validateBaseUrl('data:text/html,')).toThrow(/Unsupported protocol/); + }); + }); + + describe('parseResponse - Content-Length Integer Overflow Protection', () => { + it('should reject extremely large Content-Length values', async () => { + const response = { + ok: true, + status: 200, + headers: new Headers({ + 'content-type': 'application/json', + 'content-length': '9999999999999999999999999999', // Very large number + }), + text: () => Promise.resolve('{"data": "test"}'), + } as Response; + + // Large Content-Length should trigger rejection before reading body + await expect(parseResponse(response, { maxResponseSize: 1000 })).rejects.toThrow( + /Response size.*exceeds maximum/, + ); + }); + + it('should handle NaN Content-Length gracefully', async () => { + const response = { + ok: true, + status: 200, + headers: new Headers({ + 'content-type': 'application/json', + 'content-length': 'not-a-number', + }), + text: () => Promise.resolve('{"data": "test"}'), + } as Response; + + // Should not throw on NaN - should fall through to actual size check + const result = await parseResponse(response, { maxResponseSize: 1000 }); + expect(result.data).toEqual({ data: 'test' }); + }); + + it('should check actual byte size even when Content-Length is missing', async () => { + const largeText = 'x'.repeat(2000); + const response = { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/plain' }), + text: () => Promise.resolve(largeText), + } as Response; + + await expect(parseResponse(response, { maxResponseSize: 1000 })).rejects.toThrow( + /Response size.*exceeds maximum/, + ); + }); + }); + + describe('buildRequest - Header Injection Extended Control Characters', () => { + it('should throw error for null byte in header values', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'custom', key: 'X-Custom', type: 'header', required: true }], + }); + + const maliciousValue = 'value\x00injected'; + expect(() => { + buildRequest( + tool, + { custom: maliciousValue }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + }).toThrow(/contains control characters/); + }); + + it('should throw error for form feed in header values', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'custom', key: 'X-Custom', type: 'header', required: true }], + }); + + const maliciousValue = 'value\finjected'; + expect(() => { + buildRequest( + tool, + { custom: maliciousValue }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + }).toThrow(/contains control characters/); + }); + + it('should throw error for vertical tab in header values', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'custom', key: 'X-Custom', type: 'header', required: true }], + }); + + const maliciousValue = 'value\vinjected'; + expect(() => { + buildRequest( + tool, + { custom: maliciousValue }, + { headers: {}, query: {}, cookies: {} }, + 'https://api.example.com', + ); + }).toThrow(/contains control characters/); + }); + }); + + describe('buildRequest - Unknown Mapper Type Handling', () => { + it('should throw error for unknown mapper type', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'data', key: 'data', type: 'unknown_type' as any, required: true }], + }); + + expect(() => { + buildRequest(tool, { data: 'value' }, { headers: {}, query: {}, cookies: {} }, 'https://api.example.com'); + }).toThrow(/Unknown mapper type 'unknown_type'/); + }); + + it('should throw error for empty mapper type', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'data', key: 'data', type: '' as any, required: true }], + }); + + expect(() => { + buildRequest(tool, { data: 'value' }, { headers: {}, query: {}, cookies: {} }, 'https://api.example.com'); + }).toThrow(/Unknown mapper type/); + }); + }); + + describe('buildRequest - Query Parameter Collision Detection', () => { + it('should throw error when user input collides with security query param', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'api_key', key: 'api_key', type: 'query', required: true }], + }); + + // Security provides the same query param + const security = { headers: {}, query: { api_key: 'security-key' }, cookies: {} }; + + expect(() => { + buildRequest(tool, { api_key: 'user-key' }, security, 'https://api.example.com'); + }).toThrow(/Query parameter collision.*'api_key'/); + }); + + it('should include tool name in collision error message', () => { + const tool = createTestTool({ + name: 'getUser', + mapper: [{ inputKey: 'token', key: 'token', type: 'query', required: true }], + }); + + const security = { headers: {}, query: { token: 'security-token' }, cookies: {} }; + + expect(() => { + buildRequest(tool, { token: 'user-token' }, security, 'https://api.example.com'); + }).toThrow(/operation 'getUser'/); + }); + + it('should not throw when query params do not collide', () => { + const tool = createTestTool({ + mapper: [{ inputKey: 'filter', key: 'filter', type: 'query', required: true }], + }); + + const security = { headers: {}, query: { api_key: 'security-key' }, cookies: {} }; + + const result = buildRequest(tool, { filter: 'active' }, security, 'https://api.example.com'); + expect(result.url).toContain('filter=active'); + expect(result.url).toContain('api_key=security-key'); + }); + }); + + describe('buildRequest - Server URL from OpenAPI Spec Validation (SSRF)', () => { + it('should reject file:// protocol in OpenAPI spec server URL', () => { + const tool: McpOpenAPITool = { + name: 'maliciousTool', + description: 'Malicious tool', + inputSchema: { type: 'object', properties: {} }, + mapper: [], + metadata: { + path: '/etc/passwd', + method: 'get', + servers: [{ url: 'file:///' }], // SSRF attempt + }, + }; + + expect(() => { + buildRequest(tool, {}, { headers: {}, query: {}, cookies: {} }, 'https://api.example.com'); + }).toThrow(/Unsupported protocol/); + }); + + it('should use validated server URL from OpenAPI spec over baseUrl', () => { + const tool: McpOpenAPITool = { + name: 'testTool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + mapper: [], + metadata: { + path: '/users', + method: 'get', + servers: [{ url: 'https://specific-api.example.com' }], + }, + }; + + const result = buildRequest(tool, {}, { headers: {}, query: {}, cookies: {} }, 'https://default-api.example.com'); + expect(result.url).toBe('https://specific-api.example.com/users'); + }); + + it('should fall back to baseUrl when no servers specified', () => { + const tool: McpOpenAPITool = { + name: 'testTool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + mapper: [], + metadata: { + path: '/users', + method: 'get', + servers: [], // Empty servers array + }, + }; + + const result = buildRequest(tool, {}, { headers: {}, query: {}, cookies: {} }, 'https://default-api.example.com'); + expect(result.url).toBe('https://default-api.example.com/users'); + }); + }); +}); diff --git a/libs/adapters/src/openapi/__tests__/openapi-frontmcp-extension.spec.ts b/libs/adapters/src/openapi/__tests__/openapi-frontmcp-extension.spec.ts new file mode 100644 index 00000000..4ec74810 --- /dev/null +++ b/libs/adapters/src/openapi/__tests__/openapi-frontmcp-extension.spec.ts @@ -0,0 +1,968 @@ +/** + * Tests for x-frontmcp OpenAPI extension support + * + * The x-frontmcp extension allows embedding FrontMCP-specific configuration + * directly in OpenAPI specs for declarative tool configuration. + */ + +import { createOpenApiTool } from '../openapi.tool'; +import type { McpOpenAPITool, FrontMcpExtensionData } from 'mcp-from-openapi'; +import { spyOnConsole, createMockLogger } from './fixtures'; + +// Mock mcp-from-openapi +jest.mock('mcp-from-openapi', () => ({ + SecurityResolver: jest.fn().mockImplementation(() => ({ + resolve: jest.fn().mockResolvedValue({ + headers: {}, + query: {}, + cookies: {}, + }), + })), + createSecurityContext: jest.fn((context) => context), +})); + +/** + * Helper to create a mock OpenAPI tool with x-frontmcp extension + */ +function createMockTool( + name: string, + frontmcp?: FrontMcpExtensionData, + toolTransform?: Record, +): McpOpenAPITool { + const metadata: Record = { + path: `/test/${name}`, + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }; + + if (frontmcp) { + metadata['frontmcp'] = frontmcp; + } + + if (toolTransform) { + metadata['adapter'] = { toolTransform }; + } + + return { + name, + description: `Test tool: ${name}`, + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + mapper: [ + { + inputKey: 'id', + type: 'query', + key: 'id', + required: false, + }, + ], + metadata: metadata as McpOpenAPITool['metadata'], + }; +} + +describe('OpenapiAdapter - x-frontmcp Extension Support', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + consoleSpy = spyOnConsole(); + }); + + afterEach(() => { + consoleSpy.restore(); + }); + + describe('Annotations', () => { + it('should apply readOnlyHint annotation from x-frontmcp', () => { + const tool = createMockTool('getUser', { + annotations: { + readOnlyHint: true, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + // The tool function contains metadata, verify it was created + expect(typeof result).toBe('function'); + }); + + it('should apply destructiveHint annotation from x-frontmcp', () => { + const tool = createMockTool('deleteUser', { + annotations: { + destructiveHint: true, + readOnlyHint: false, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply idempotentHint annotation from x-frontmcp', () => { + const tool = createMockTool('updateUser', { + annotations: { + idempotentHint: true, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply openWorldHint annotation from x-frontmcp', () => { + const tool = createMockTool('callExternalApi', { + annotations: { + openWorldHint: true, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply title annotation from x-frontmcp', () => { + const tool = createMockTool('getUsers', { + annotations: { + title: 'List All Users', + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply multiple annotations from x-frontmcp', () => { + const tool = createMockTool('listUsers', { + annotations: { + title: 'List Users', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, + destructiveHint: false, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); + + describe('Cache Configuration', () => { + it('should apply cache ttl from x-frontmcp', () => { + const tool = createMockTool('getCachedData', { + cache: { + ttl: 300, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply cache slideWindow from x-frontmcp', () => { + const tool = createMockTool('getSlideWindowData', { + cache: { + ttl: 600, + slideWindow: true, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply cache config without slideWindow (default false)', () => { + const tool = createMockTool('getFixedCacheData', { + cache: { + ttl: 120, + slideWindow: false, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); + + describe('CodeCall Configuration', () => { + it('should apply enabledInCodeCall from x-frontmcp', () => { + const tool = createMockTool('codeCallTool', { + codecall: { + enabledInCodeCall: true, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply visibleInListTools from x-frontmcp', () => { + const tool = createMockTool('visibleTool', { + codecall: { + visibleInListTools: true, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply full codecall config from x-frontmcp', () => { + const tool = createMockTool('fullCodeCallTool', { + codecall: { + enabledInCodeCall: true, + visibleInListTools: true, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply disabled codecall config from x-frontmcp', () => { + const tool = createMockTool('disabledCodeCallTool', { + codecall: { + enabledInCodeCall: false, + visibleInListTools: false, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); + + describe('Tags', () => { + it('should apply single tag from x-frontmcp', () => { + const tool = createMockTool('taggedTool', { + tags: ['users'], + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply multiple tags from x-frontmcp', () => { + const tool = createMockTool('multiTagTool', { + tags: ['users', 'admin', 'public-api'], + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply empty tags array from x-frontmcp', () => { + const tool = createMockTool('noTagsTool', { + tags: [], + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); + + describe('Hide From Discovery', () => { + it('should apply hideFromDiscovery true from x-frontmcp', () => { + const tool = createMockTool('hiddenTool', { + hideFromDiscovery: true, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply hideFromDiscovery false from x-frontmcp', () => { + const tool = createMockTool('visibleTool', { + hideFromDiscovery: false, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); + + describe('Examples', () => { + it('should apply single example from x-frontmcp', () => { + const tool = createMockTool('exampleTool', { + examples: [ + { + description: 'Get user by ID', + input: { id: 'user-123' }, + }, + ], + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply multiple examples from x-frontmcp', () => { + const tool = createMockTool('multiExampleTool', { + examples: [ + { + description: 'Get user by ID', + input: { id: 'user-123' }, + output: { name: 'John', email: 'john@example.com' }, + }, + { + description: 'Get admin user', + input: { id: 'admin-1' }, + output: { name: 'Admin', email: 'admin@example.com', isAdmin: true }, + }, + ], + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply example with output from x-frontmcp', () => { + const tool = createMockTool('outputExampleTool', { + examples: [ + { + description: 'Example with expected output', + input: { query: 'test' }, + output: { results: [], count: 0 }, + }, + ], + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); + + describe('Full x-frontmcp Extension', () => { + it('should apply complete x-frontmcp extension with all options', () => { + const tool = createMockTool('fullExtensionTool', { + annotations: { + title: 'Full Extension Tool', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + destructiveHint: false, + }, + cache: { + ttl: 300, + slideWindow: true, + }, + codecall: { + enabledInCodeCall: true, + visibleInListTools: true, + }, + tags: ['api', 'users', 'public'], + hideFromDiscovery: false, + examples: [ + { + description: 'Get all users', + input: {}, + output: { users: [], total: 0 }, + }, + ], + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); + + describe('ToolTransforms Override x-frontmcp', () => { + it('should allow toolTransforms to override x-frontmcp annotations', () => { + // x-frontmcp sets readOnlyHint: true, toolTransform overrides to false + const tool = createMockTool( + 'overrideTool', + { + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + { + annotations: { + readOnlyHint: false, // Override from adapter + destructiveHint: true, // New annotation from adapter + }, + }, + ); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should merge toolTransforms tags with x-frontmcp tags', () => { + const tool = createMockTool( + 'mergeTagsTool', + { + tags: ['api', 'users'], + }, + { + tags: ['internal', 'v2'], + }, + ); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should allow toolTransforms to override hideFromDiscovery', () => { + // x-frontmcp sets hideFromDiscovery: false, toolTransform overrides to true + const tool = createMockTool( + 'hideOverrideTool', + { + hideFromDiscovery: false, + }, + { + hideFromDiscovery: true, + }, + ); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should merge toolTransforms examples with x-frontmcp examples', () => { + const tool = createMockTool( + 'mergeExamplesTool', + { + examples: [{ description: 'Example from spec', input: { id: '1' } }], + }, + { + examples: [{ description: 'Example from adapter', input: { id: '2' } }], + }, + ); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply toolTransforms UI config (not available in x-frontmcp)', () => { + const tool = createMockTool( + 'uiConfigTool', + { + annotations: { readOnlyHint: true }, + }, + { + ui: { + form: { layout: 'horizontal' }, + }, + }, + ); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); + + describe('No x-frontmcp Extension', () => { + it('should work without x-frontmcp extension', () => { + const tool = createMockTool('noExtensionTool'); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply only toolTransforms when no x-frontmcp', () => { + const tool = createMockTool('onlyTransformsTool', undefined, { + annotations: { readOnlyHint: true }, + tags: ['adapter-only'], + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); + + describe('Partial x-frontmcp Extension', () => { + it('should apply only annotations when other fields are not set', () => { + const tool = createMockTool('annotationsOnlyTool', { + annotations: { + readOnlyHint: true, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply only cache when other fields are not set', () => { + const tool = createMockTool('cacheOnlyTool', { + cache: { + ttl: 60, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should apply only tags when other fields are not set', () => { + const tool = createMockTool('tagsOnlyTool', { + tags: ['standalone-tag'], + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty x-frontmcp extension object', () => { + const tool = createMockTool('emptyExtensionTool', {}); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should handle undefined annotations object', () => { + const tool = createMockTool('undefinedAnnotationsTool', { + tags: ['test'], + annotations: undefined, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should handle empty annotations object', () => { + const tool = createMockTool('emptyAnnotationsTool', { + annotations: {}, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should handle cache with only ttl', () => { + const tool = createMockTool('ttlOnlyCacheTool', { + cache: { + ttl: 100, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + + it('should handle codecall with only one property', () => { + const tool = createMockTool('partialCodeCallTool', { + codecall: { + enabledInCodeCall: true, + }, + }); + + const mockLogger = createMockLogger(); + const result = createOpenApiTool( + tool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); + + expect(result).toBeDefined(); + }); + }); +}); diff --git a/libs/adapters/src/openapi/__tests__/openapi-loading.spec.ts b/libs/adapters/src/openapi/__tests__/openapi-loading.spec.ts index 01245315..41fb1ba4 100644 --- a/libs/adapters/src/openapi/__tests__/openapi-loading.spec.ts +++ b/libs/adapters/src/openapi/__tests__/openapi-loading.spec.ts @@ -3,7 +3,7 @@ */ import OpenapiAdapter from '../openapi.adapter'; -import { basicOpenApiSpec, spyOnConsole } from './fixtures'; +import { basicOpenApiSpec, spyOnConsole, createMockLogger } from './fixtures'; // Mock the OpenAPIToolGenerator jest.mock('mcp-from-openapi', () => ({ @@ -46,6 +46,7 @@ describe('OpenapiAdapter - Loading', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), }); await adapter.fetch(); @@ -56,7 +57,7 @@ describe('OpenapiAdapter - Loading', () => { baseUrl: 'https://api.example.com', validate: true, dereference: true, - }) + }), ); expect(OpenAPIToolGenerator.fromURL).not.toHaveBeenCalled(); }); @@ -88,14 +89,12 @@ describe('OpenapiAdapter - Loading', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: complexSpec, + logger: createMockLogger(), }); await adapter.fetch(); - expect(OpenAPIToolGenerator.fromJSON).toHaveBeenCalledWith( - complexSpec, - expect.any(Object) - ); + expect(OpenAPIToolGenerator.fromJSON).toHaveBeenCalledWith(complexSpec, expect.any(Object)); }); }); @@ -112,6 +111,7 @@ describe('OpenapiAdapter - Loading', () => { name: 'test-api', baseUrl: 'https://api.example.com', url: 'https://api.example.com/openapi.json', + logger: createMockLogger(), }); await adapter.fetch(); @@ -122,7 +122,7 @@ describe('OpenapiAdapter - Loading', () => { baseUrl: 'https://api.example.com', validate: true, dereference: true, - }) + }), ); expect(OpenAPIToolGenerator.fromJSON).not.toHaveBeenCalled(); }); @@ -139,9 +139,10 @@ describe('OpenapiAdapter - Loading', () => { name: 'test-api', baseUrl: 'https://api.example.com', url: 'https://api.example.com/openapi.json', + logger: createMockLogger(), loadOptions: { headers: { - 'Authorization': 'Bearer spec-token', + Authorization: 'Bearer spec-token', }, timeout: 60000, followRedirects: false, @@ -154,25 +155,24 @@ describe('OpenapiAdapter - Loading', () => { 'https://api.example.com/openapi.json', expect.objectContaining({ headers: { - 'Authorization': 'Bearer spec-token', + Authorization: 'Bearer spec-token', }, timeout: 60000, followRedirects: false, - }) + }), ); }); it('should handle URL loading errors', async () => { const { OpenAPIToolGenerator } = require('mcp-from-openapi'); - OpenAPIToolGenerator.fromURL.mockRejectedValue( - new Error('Failed to fetch OpenAPI spec') - ); + OpenAPIToolGenerator.fromURL.mockRejectedValue(new Error('Failed to fetch OpenAPI spec')); const adapter = new OpenapiAdapter({ name: 'test-api', baseUrl: 'https://api.example.com', url: 'https://api.example.com/openapi.json', + logger: createMockLogger(), }); await expect(adapter.fetch()).rejects.toThrow('Failed to fetch OpenAPI spec'); @@ -192,6 +192,7 @@ describe('OpenapiAdapter - Loading', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), }); await adapter.fetch(); @@ -200,7 +201,7 @@ describe('OpenapiAdapter - Loading', () => { basicOpenApiSpec, expect.objectContaining({ validate: true, - }) + }), ); }); @@ -216,6 +217,7 @@ describe('OpenapiAdapter - Loading', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), loadOptions: { validate: false, }, @@ -227,7 +229,7 @@ describe('OpenapiAdapter - Loading', () => { basicOpenApiSpec, expect.objectContaining({ validate: false, - }) + }), ); }); }); @@ -245,6 +247,7 @@ describe('OpenapiAdapter - Loading', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), }); await adapter.fetch(); @@ -253,7 +256,7 @@ describe('OpenapiAdapter - Loading', () => { basicOpenApiSpec, expect.objectContaining({ dereference: true, - }) + }), ); }); @@ -269,6 +272,7 @@ describe('OpenapiAdapter - Loading', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: basicOpenApiSpec, + logger: createMockLogger(), loadOptions: { dereference: false, }, @@ -280,7 +284,7 @@ describe('OpenapiAdapter - Loading', () => { basicOpenApiSpec, expect.objectContaining({ dereference: false, - }) + }), ); }); }); @@ -298,6 +302,7 @@ describe('OpenapiAdapter - Loading', () => { name: 'test-api', baseUrl: 'https://custom.example.com/v2', spec: basicOpenApiSpec, + logger: createMockLogger(), }); await adapter.fetch(); @@ -306,7 +311,7 @@ describe('OpenapiAdapter - Loading', () => { basicOpenApiSpec, expect.objectContaining({ baseUrl: 'https://custom.example.com/v2', - }) + }), ); }); }); diff --git a/libs/adapters/src/openapi/__tests__/openapi-security-unit.spec.ts b/libs/adapters/src/openapi/__tests__/openapi-security-unit.spec.ts new file mode 100644 index 00000000..a5b7be0b --- /dev/null +++ b/libs/adapters/src/openapi/__tests__/openapi-security-unit.spec.ts @@ -0,0 +1,482 @@ +/** + * Unit tests for OpenAPI security functions + */ + +import { createSecurityContextFromAuth } from '../openapi.security'; +import type { McpOpenAPITool } from 'mcp-from-openapi'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; + +// Mock mcp-from-openapi +jest.mock('mcp-from-openapi', () => ({ + SecurityResolver: jest.fn().mockImplementation(() => ({ + resolve: jest.fn().mockResolvedValue({ + headers: {}, + query: {}, + cookies: {}, + }), + })), + createSecurityContext: jest.fn((context) => context), +})); + +describe('OpenapiAdapter - Security Unit Tests', () => { + const mockAuthInfo: AuthInfo = { + token: 'test-token', + clientId: 'test-client', + scopes: [], + }; + + const createMockTool = (scheme: string): McpOpenAPITool => ({ + name: 'testTool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + mapper: [ + { + inputKey: 'auth', + type: 'header', + key: 'Authorization', + required: true, + security: { + scheme, + type: 'http', + httpScheme: 'bearer', + }, + }, + ], + metadata: { + path: '/test', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }); + + describe('createSecurityContextFromAuth - authProviderMapper validation', () => { + it('should throw error when authProviderMapper returns a number', async () => { + const tool = createMockTool('BearerAuth'); + + await expect( + createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => 12345 as any, // Returns number instead of string + }, + }), + ).rejects.toThrow(/authProviderMapper\['BearerAuth'\] must return a string or undefined.*returned: number/); + }); + + it('should throw error when authProviderMapper returns an object', async () => { + const tool = createMockTool('BearerAuth'); + + await expect( + createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => ({ token: 'abc' } as any), // Returns object instead of string + }, + }), + ).rejects.toThrow(/authProviderMapper\['BearerAuth'\] must return a string or undefined.*returned: object/); + }); + + it('should throw error when authProviderMapper returns an array', async () => { + const tool = createMockTool('BearerAuth'); + + await expect( + createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => ['token1', 'token2'] as any, // Returns array instead of string + }, + }), + ).rejects.toThrow(/authProviderMapper\['BearerAuth'\] must return a string or undefined.*returned: object/); + }); + + it('should throw error when authProviderMapper returns a boolean', async () => { + const tool = createMockTool('BearerAuth'); + + await expect( + createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => true as any, // Returns boolean instead of string + }, + }), + ).rejects.toThrow(/authProviderMapper\['BearerAuth'\] must return a string or undefined.*returned: boolean/); + }); + + it('should throw descriptive error when authProviderMapper throws an exception', async () => { + const tool = createMockTool('BearerAuth'); + + await expect( + createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => { + throw new Error('Custom auth extraction failed'); + }, + }, + }), + ).rejects.toThrow(/authProviderMapper\['BearerAuth'\] threw an error: Custom auth extraction failed/); + }); + + it('should wrap non-Error throws from authProviderMapper', async () => { + const tool = createMockTool('BearerAuth'); + + await expect( + createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => { + throw 'string error'; // Throwing a non-Error + }, + }, + }), + ).rejects.toThrow(/authProviderMapper\['BearerAuth'\] threw an error: string error/); + }); + + it('should accept valid string return from authProviderMapper', async () => { + const tool = createMockTool('BearerAuth'); + + const result = await createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => 'valid-token-string', + }, + }); + + expect(result.jwt).toBe('valid-token-string'); + }); + + it('should accept undefined return from authProviderMapper and fall back to authInfo.token', async () => { + const tool = createMockTool('BearerAuth'); + + // Should not throw - undefined is allowed + const result = await createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => undefined, + }, + }); + + // Falls back to authInfo.token when mapper returns undefined + expect(result.jwt).toBe('test-token'); + }); + + it('should accept null return from authProviderMapper and fall back to authInfo.token', async () => { + const tool = createMockTool('BearerAuth'); + + // Should not throw - null is allowed + const result = await createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => null as any, + }, + }); + + // Falls back to authInfo.token when mapper returns null + expect(result.jwt).toBe('test-token'); + }); + + it('should fall back to authInfo.token when mapper returns undefined', async () => { + const tool = createMockTool('BearerAuth'); + + const result = await createSecurityContextFromAuth( + tool, + { ...mockAuthInfo, token: 'fallback-token' }, + { + authProviderMapper: { + BearerAuth: () => undefined, + }, + }, + ); + + expect(result.jwt).toBe('fallback-token'); + }); + + it('should use first matching token from multiple mappers', async () => { + const tool: McpOpenAPITool = { + name: 'testTool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + mapper: [ + { + inputKey: 'auth1', + type: 'header', + key: 'Authorization', + required: true, + security: { scheme: 'Scheme1', type: 'http', httpScheme: 'bearer' }, + }, + { + inputKey: 'auth2', + type: 'header', + key: 'X-API-Key', + required: true, + security: { scheme: 'Scheme2', type: 'apiKey' }, + }, + ], + metadata: { + path: '/test', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const result = await createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + Scheme1: () => 'token-from-scheme1', + Scheme2: () => 'token-from-scheme2', + }, + }); + + // First token wins + expect(result.jwt).toBe('token-from-scheme1'); + }); + }); + + describe('createSecurityContextFromAuth - priority order', () => { + it('should use custom securityResolver when provided (highest priority)', async () => { + const tool = createMockTool('BearerAuth'); + const customResolver = jest.fn().mockResolvedValue({ jwt: 'custom-token' }); + + const result = await createSecurityContextFromAuth(tool, mockAuthInfo, { + securityResolver: customResolver, + authProviderMapper: { BearerAuth: () => 'mapper-token' }, + staticAuth: { jwt: 'static-token' }, + }); + + expect(customResolver).toHaveBeenCalledWith(tool, mockAuthInfo); + expect(result).toEqual({ jwt: 'custom-token' }); + }); + + it('should use staticAuth when no securityResolver and no authProviderMapper', async () => { + const { createSecurityContext } = require('mcp-from-openapi'); + const tool = createMockTool('BearerAuth'); + + await createSecurityContextFromAuth(tool, mockAuthInfo, { + staticAuth: { jwt: 'static-token' }, + }); + + expect(createSecurityContext).toHaveBeenCalledWith({ jwt: 'static-token' }); + }); + + it('should use default authInfo.token when no configuration provided', async () => { + const { createSecurityContext } = require('mcp-from-openapi'); + const tool = createMockTool('BearerAuth'); + + await createSecurityContextFromAuth(tool, { ...mockAuthInfo, token: 'default-token' }, {}); + + expect(createSecurityContext).toHaveBeenCalledWith({ jwt: 'default-token' }); + }); + }); + + describe('createSecurityContextFromAuth - empty string token rejection', () => { + it('should throw error when authProviderMapper returns empty string', async () => { + const tool = createMockTool('BearerAuth'); + + await expect( + createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => '', // Returns empty string + }, + }), + ).rejects.toThrow(/authProviderMapper\['BearerAuth'\] returned empty string/); + }); + + it('should include helpful message about returning undefined instead', async () => { + const tool = createMockTool('BearerAuth'); + + await expect( + createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => '', + }, + }), + ).rejects.toThrow(/Return undefined\/null if no token is available/); + }); + }); + + describe('createSecurityContextFromAuth - auth type routing', () => { + it('should route apiKey scheme to context.apiKey field', async () => { + const tool: McpOpenAPITool = { + name: 'testTool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + mapper: [ + { + inputKey: 'apiKey', + type: 'header', + key: 'X-API-Key', + required: true, + security: { scheme: 'ApiKeyAuth', type: 'apiKey' }, + }, + ], + metadata: { + path: '/test', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const result = await createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + ApiKeyAuth: () => 'my-api-key', + }, + }); + + expect(result.apiKey).toBe('my-api-key'); + expect(result.jwt).toBeUndefined(); + }); + + it('should route http basic scheme to context.basic field', async () => { + const tool: McpOpenAPITool = { + name: 'testTool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + mapper: [ + { + inputKey: 'auth', + type: 'header', + key: 'Authorization', + required: true, + security: { scheme: 'BasicAuth', type: 'http', httpScheme: 'basic' }, + }, + ], + metadata: { + path: '/test', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const result = await createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BasicAuth: () => 'dXNlcjpwYXNz', // base64 encoded user:pass + }, + }); + + expect(result.basic).toBe('dXNlcjpwYXNz'); + expect(result.jwt).toBeUndefined(); + }); + + it('should route oauth2 scheme to context.oauth2Token field', async () => { + const tool: McpOpenAPITool = { + name: 'testTool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + mapper: [ + { + inputKey: 'auth', + type: 'header', + key: 'Authorization', + required: true, + security: { scheme: 'OAuth2Auth', type: 'oauth2' }, + }, + ], + metadata: { + path: '/test', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const result = await createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + OAuth2Auth: () => 'oauth2-access-token', + }, + }); + + expect(result.oauth2Token).toBe('oauth2-access-token'); + expect(result.jwt).toBeUndefined(); + }); + + it('should route http bearer scheme to context.jwt field', async () => { + const tool = createMockTool('BearerAuth'); // Uses http bearer by default + + const result = await createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => 'bearer-jwt-token', + }, + }); + + expect(result.jwt).toBe('bearer-jwt-token'); + }); + + it('should handle multiple auth types routing each to correct field', async () => { + const tool: McpOpenAPITool = { + name: 'testTool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + mapper: [ + { + inputKey: 'jwt', + type: 'header', + key: 'Authorization', + required: true, + security: { scheme: 'BearerAuth', type: 'http', httpScheme: 'bearer' }, + }, + { + inputKey: 'apiKey', + type: 'header', + key: 'X-API-Key', + required: true, + security: { scheme: 'ApiKeyAuth', type: 'apiKey' }, + }, + ], + metadata: { + path: '/test', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const result = await createSecurityContextFromAuth(tool, mockAuthInfo, { + authProviderMapper: { + BearerAuth: () => 'my-jwt', + ApiKeyAuth: () => 'my-api-key', + }, + }); + + expect(result.jwt).toBe('my-jwt'); + expect(result.apiKey).toBe('my-api-key'); + }); + }); + + describe('createSecurityContextFromAuth - authInfo.token type validation', () => { + it('should throw error when authInfo.token is not a string', async () => { + const tool = createMockTool('BearerAuth'); + const invalidAuthInfo = { + ...mockAuthInfo, + token: { nested: 'object' } as any, // Invalid token type + }; + + await expect( + createSecurityContextFromAuth(tool, invalidAuthInfo, { + authProviderMapper: { + BearerAuth: () => undefined, // Returns undefined to trigger fallback + }, + }), + ).rejects.toThrow(/authInfo\.token must be a string.*got: object/); + }); + + it('should throw error when authInfo.token is a number', async () => { + const tool = createMockTool('BearerAuth'); + const invalidAuthInfo = { + ...mockAuthInfo, + token: 12345 as any, // Invalid token type + }; + + await expect( + createSecurityContextFromAuth(tool, invalidAuthInfo, { + authProviderMapper: { + BearerAuth: () => undefined, + }, + }), + ).rejects.toThrow(/authInfo\.token must be a string.*got: number/); + }); + + it('should accept valid string authInfo.token', async () => { + const tool = createMockTool('BearerAuth'); + + const result = await createSecurityContextFromAuth( + tool, + { ...mockAuthInfo, token: 'valid-fallback-token' }, + { + authProviderMapper: { + BearerAuth: () => undefined, // Returns undefined to trigger fallback + }, + }, + ); + + expect(result.jwt).toBe('valid-fallback-token'); + }); + }); +}); diff --git a/libs/adapters/src/openapi/__tests__/openapi-security.spec.ts b/libs/adapters/src/openapi/__tests__/openapi-security.spec.ts index 627d8e9f..e5ff3440 100644 --- a/libs/adapters/src/openapi/__tests__/openapi-security.spec.ts +++ b/libs/adapters/src/openapi/__tests__/openapi-security.spec.ts @@ -3,7 +3,7 @@ */ import OpenapiAdapter from '../openapi.adapter'; -import { bearerAuthSpec, multiAuthSpec, mockAuthInfo, spyOnConsole } from './fixtures'; +import { bearerAuthSpec, multiAuthSpec, mockAuthInfo, spyOnConsole, createMockLogger } from './fixtures'; import type { McpOpenAPITool } from 'mcp-from-openapi'; // Mock the OpenAPIToolGenerator and security @@ -75,10 +75,12 @@ describe('OpenapiAdapter - Security', () => { }; OpenAPIToolGenerator.fromJSON.mockResolvedValue(mockGenerator); + const mockLogger = createMockLogger(); const adapter = new OpenapiAdapter({ name: 'test-api', baseUrl: 'https://api.example.com', spec: multiAuthSpec, + logger: mockLogger, authProviderMapper: { GitHubAuth: (authInfo) => authInfo.user?.githubToken, SlackAuth: (authInfo) => authInfo.user?.slackToken, @@ -88,12 +90,8 @@ describe('OpenapiAdapter - Security', () => { await adapter.fetch(); // Should log LOW security risk - expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('Security Risk Score: LOW') - ); - expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('Valid Configuration: YES') - ); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Security Risk Score: LOW')); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Valid Configuration: YES')); }); it('should fail with missing auth provider mapping', async () => { @@ -132,6 +130,7 @@ describe('OpenapiAdapter - Security', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: multiAuthSpec, + logger: createMockLogger(), authProviderMapper: { SlackAuth: (authInfo) => authInfo.user?.slackToken, // GitHubAuth is missing! @@ -179,19 +178,19 @@ describe('OpenapiAdapter - Security', () => { jwt: authInfo.token, })); + const mockLogger = createMockLogger(); const adapter = new OpenapiAdapter({ name: 'test-api', baseUrl: 'https://api.example.com', spec: bearerAuthSpec, + logger: mockLogger, securityResolver: customResolver, }); await adapter.fetch(); // Should log LOW security risk (custom resolver) - expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('Security Risk Score: LOW') - ); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Security Risk Score: LOW')); }); }); @@ -228,10 +227,12 @@ describe('OpenapiAdapter - Security', () => { }; OpenAPIToolGenerator.fromJSON.mockResolvedValue(mockGenerator); + const mockLogger = createMockLogger(); const adapter = new OpenapiAdapter({ name: 'test-api', baseUrl: 'https://api.example.com', spec: bearerAuthSpec, + logger: mockLogger, staticAuth: { jwt: 'static-jwt-token', }, @@ -240,12 +241,8 @@ describe('OpenapiAdapter - Security', () => { await adapter.fetch(); // Should log MEDIUM security risk (static auth) - expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('Security Risk Score: MEDIUM') - ); - expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('Using staticAuth') - ); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Security Risk Score: MEDIUM')); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Using staticAuth')); }); }); @@ -282,22 +279,20 @@ describe('OpenapiAdapter - Security', () => { }; OpenAPIToolGenerator.fromJSON.mockResolvedValue(mockGenerator); + const mockLogger = createMockLogger(); const adapter = new OpenapiAdapter({ name: 'test-api', baseUrl: 'https://api.example.com', spec: bearerAuthSpec, + logger: mockLogger, // No auth configuration }); await adapter.fetch(); // Should log MEDIUM security risk (default behavior) - expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('Security Risk Score: MEDIUM') - ); - expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('No auth configuration provided') - ); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Security Risk Score: MEDIUM')); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('No auth configuration provided')); }); }); @@ -340,10 +335,12 @@ describe('OpenapiAdapter - Security', () => { }; OpenAPIToolGenerator.fromJSON.mockResolvedValue(mockGenerator); + const mockLogger = createMockLogger(); const adapter = new OpenapiAdapter({ name: 'test-api', baseUrl: 'https://api.example.com', spec: bearerAuthSpec, + logger: mockLogger, generateOptions: { includeSecurityInInput: true, }, @@ -352,15 +349,10 @@ describe('OpenapiAdapter - Security', () => { await adapter.fetch(); // Should log HIGH security risk - expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('Security Risk Score: HIGH') - ); - expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('SECURITY WARNING') - ); - expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('includeSecurityInInput is enabled') - ); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Security Risk Score: HIGH')); + // SECURITY WARNING is logged via warn method + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('SECURITY WARNING')); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('includeSecurityInInput is enabled')); }); }); @@ -377,6 +369,7 @@ describe('OpenapiAdapter - Security', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: bearerAuthSpec, + logger: createMockLogger(), additionalHeaders: { 'X-API-Key': 'test-key', 'X-Custom-Header': 'custom-value', @@ -410,6 +403,7 @@ describe('OpenapiAdapter - Security', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: bearerAuthSpec, + logger: createMockLogger(), headersMapper, }); @@ -437,6 +431,7 @@ describe('OpenapiAdapter - Security', () => { name: 'test-api', baseUrl: 'https://api.example.com', spec: bearerAuthSpec, + logger: createMockLogger(), bodyMapper, }); @@ -445,4 +440,200 @@ describe('OpenapiAdapter - Security', () => { expect(adapter.options.bodyMapper).toBe(bodyMapper); }); }); + + describe('securitySchemesInInput', () => { + it('should filter security schemes - keep only specified schemes in input', async () => { + const { OpenAPIToolGenerator } = require('mcp-from-openapi'); + + const mockTool: McpOpenAPITool = { + name: 'multiAuthTool', + description: 'Tool with multiple auth', + inputSchema: { + type: 'object', + properties: { + GitHubAuth: { type: 'string' }, + ApiKeyAuth: { type: 'string' }, + }, + required: ['GitHubAuth', 'ApiKeyAuth'], + }, + mapper: [ + { + inputKey: 'GitHubAuth', + type: 'header', + key: 'Authorization', + required: true, + security: { scheme: 'GitHubAuth', type: 'http', httpScheme: 'bearer' }, + }, + { + inputKey: 'ApiKeyAuth', + type: 'header', + key: 'X-API-Key', + required: true, + security: { scheme: 'ApiKeyAuth', type: 'apiKey', apiKeyIn: 'header', apiKeyName: 'X-API-Key' }, + }, + ], + metadata: { + path: '/multi-auth', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const mockGenerator = { + generateTools: jest.fn().mockResolvedValue([mockTool]), + }; + OpenAPIToolGenerator.fromJSON.mockResolvedValue(mockGenerator); + + const mockLogger = createMockLogger(); + const adapter = new OpenapiAdapter({ + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: multiAuthSpec, + logger: mockLogger, + // Only ApiKeyAuth should be in input + securitySchemesInInput: ['ApiKeyAuth'], + // GitHubAuth comes from context + authProviderMapper: { + GitHubAuth: (authInfo) => authInfo.user?.githubToken, + }, + }); + + const result = await adapter.fetch(); + + // Should have generated tool with filtered input schema + expect(result.tools).toHaveLength(1); + + // Generator should have been called with includeSecurityInInput: true + expect(mockGenerator.generateTools).toHaveBeenCalledWith( + expect.objectContaining({ + includeSecurityInInput: true, + }), + ); + + // Should log info about per-scheme control + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Per-scheme security control enabled')); + }); + + it('should validate only non-input schemes have mappings', async () => { + const { OpenAPIToolGenerator } = require('mcp-from-openapi'); + + const mockTool: McpOpenAPITool = { + name: 'multiAuthTool', + description: 'Tool with multiple auth', + inputSchema: { + type: 'object', + properties: { + GitHubAuth: { type: 'string' }, + SlackAuth: { type: 'string' }, + }, + required: ['GitHubAuth', 'SlackAuth'], + }, + mapper: [ + { + inputKey: 'GitHubAuth', + type: 'header', + key: 'Authorization', + required: true, + security: { scheme: 'GitHubAuth', type: 'http', httpScheme: 'bearer' }, + }, + { + inputKey: 'SlackAuth', + type: 'header', + key: 'X-Slack-Token', + required: true, + security: { scheme: 'SlackAuth', type: 'http', httpScheme: 'bearer' }, + }, + ], + metadata: { + path: '/multi-auth', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const mockGenerator = { + generateTools: jest.fn().mockResolvedValue([mockTool]), + }; + OpenAPIToolGenerator.fromJSON.mockResolvedValue(mockGenerator); + + const mockLogger = createMockLogger(); + const adapter = new OpenapiAdapter({ + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: multiAuthSpec, + logger: mockLogger, + // GitHubAuth in input (user provides) + securitySchemesInInput: ['GitHubAuth'], + // SlackAuth from context - but NO mapping provided -> should fail + // No authProviderMapper + }); + + // Should throw because SlackAuth has no mapping + await expect(adapter.fetch()).rejects.toThrow(/Missing auth provider mappings.*SlackAuth/); + }); + + it('should pass validation when all non-input schemes have mappings', async () => { + const { OpenAPIToolGenerator } = require('mcp-from-openapi'); + + const mockTool: McpOpenAPITool = { + name: 'multiAuthTool', + description: 'Tool with multiple auth', + inputSchema: { + type: 'object', + properties: { + GitHubAuth: { type: 'string' }, + SlackAuth: { type: 'string' }, + }, + required: ['GitHubAuth', 'SlackAuth'], + }, + mapper: [ + { + inputKey: 'GitHubAuth', + type: 'header', + key: 'Authorization', + required: true, + security: { scheme: 'GitHubAuth', type: 'http', httpScheme: 'bearer' }, + }, + { + inputKey: 'SlackAuth', + type: 'header', + key: 'X-Slack-Token', + required: true, + security: { scheme: 'SlackAuth', type: 'http', httpScheme: 'bearer' }, + }, + ], + metadata: { + path: '/multi-auth', + method: 'get', + servers: [{ url: 'https://api.example.com' }], + }, + }; + + const mockGenerator = { + generateTools: jest.fn().mockResolvedValue([mockTool]), + }; + OpenAPIToolGenerator.fromJSON.mockResolvedValue(mockGenerator); + + const mockLogger = createMockLogger(); + const adapter = new OpenapiAdapter({ + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: multiAuthSpec, + logger: mockLogger, + // GitHubAuth in input (user provides) + securitySchemesInInput: ['GitHubAuth'], + // SlackAuth from context - mapping provided + authProviderMapper: { + SlackAuth: (authInfo) => authInfo.user?.slackToken, + }, + }); + + // Should not throw + const result = await adapter.fetch(); + expect(result.tools).toHaveLength(1); + + // Security risk should be MEDIUM (per-scheme control) + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Security Risk Score: MEDIUM')); + }); + }); }); diff --git a/libs/adapters/src/openapi/__tests__/openapi-tools.spec.ts b/libs/adapters/src/openapi/__tests__/openapi-tools.spec.ts index ee51f89e..a3568264 100644 --- a/libs/adapters/src/openapi/__tests__/openapi-tools.spec.ts +++ b/libs/adapters/src/openapi/__tests__/openapi-tools.spec.ts @@ -4,6 +4,7 @@ import { createOpenApiTool } from '../openapi.tool'; import type { McpOpenAPITool } from 'mcp-from-openapi'; +import { createMockLogger } from './fixtures'; // Mock mcp-from-openapi jest.mock('mcp-from-openapi', () => ({ @@ -49,11 +50,17 @@ describe('OpenapiAdapter - Tool Creation', () => { }, }; - const tool = createOpenApiTool(mockOpenApiTool, { - name: 'test-api', - baseUrl: 'https://api.example.com', - spec: {} as any, - }); + const mockLogger = createMockLogger(); + const tool = createOpenApiTool( + mockOpenApiTool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); expect(tool).toBeDefined(); expect(typeof tool).toBe('function'); @@ -92,11 +99,17 @@ describe('OpenapiAdapter - Tool Creation', () => { }, }; - const tool = createOpenApiTool(mockOpenApiTool, { - name: 'test-api', - baseUrl: 'https://api.example.com', - spec: {} as any, - }); + const mockLogger = createMockLogger(); + const tool = createOpenApiTool( + mockOpenApiTool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); expect(tool).toBeDefined(); expect(typeof tool).toBe('function'); @@ -134,11 +147,17 @@ describe('OpenapiAdapter - Tool Creation', () => { }, }; - const tool = createOpenApiTool(mockOpenApiTool, { - name: 'test-api', - baseUrl: 'https://api.example.com', - spec: {} as any, - }); + const mockLogger = createMockLogger(); + const tool = createOpenApiTool( + mockOpenApiTool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); expect(tool).toBeDefined(); }); @@ -168,11 +187,17 @@ describe('OpenapiAdapter - Tool Creation', () => { }, }; - const tool = createOpenApiTool(mockOpenApiTool, { - name: 'test-api', - baseUrl: 'https://api.example.com', - spec: {} as any, - }); + const mockLogger = createMockLogger(); + const tool = createOpenApiTool( + mockOpenApiTool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); expect(tool).toBeDefined(); }); @@ -205,11 +230,17 @@ describe('OpenapiAdapter - Tool Creation', () => { }, }; - const tool = createOpenApiTool(mockOpenApiTool, { - name: 'test-api', - baseUrl: 'https://api.example.com', - spec: {} as any, - }); + const mockLogger = createMockLogger(); + const tool = createOpenApiTool( + mockOpenApiTool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); expect(tool).toBeDefined(); }); @@ -227,15 +258,21 @@ describe('OpenapiAdapter - Tool Creation', () => { }, }; - const tool = createOpenApiTool(mockOpenApiTool, { - name: 'test-api', - baseUrl: 'https://api.example.com', - spec: {} as any, - additionalHeaders: { - 'X-API-Key': 'test-key', - 'X-Client-ID': 'client-123', + const mockLogger = createMockLogger(); + const tool = createOpenApiTool( + mockOpenApiTool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + additionalHeaders: { + 'X-API-Key': 'test-key', + 'X-Client-ID': 'client-123', + }, }, - }); + mockLogger, + ); expect(tool).toBeDefined(); }); @@ -275,13 +312,19 @@ describe('OpenapiAdapter - Tool Creation', () => { createdBy: authInfo.user?.email, })); - const tool = createOpenApiTool(mockOpenApiTool, { - name: 'test-api', - baseUrl: 'https://api.example.com', - spec: {} as any, - headersMapper, - bodyMapper, - }); + const mockLogger = createMockLogger(); + const tool = createOpenApiTool( + mockOpenApiTool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + headersMapper, + bodyMapper, + }, + mockLogger, + ); expect(tool).toBeDefined(); }); @@ -334,11 +377,17 @@ describe('OpenapiAdapter - Tool Creation', () => { }, }; - const tool = createOpenApiTool(mockOpenApiTool, { - name: 'test-api', - baseUrl: 'https://api.example.com', - spec: {} as any, - }); + const mockLogger = createMockLogger(); + const tool = createOpenApiTool( + mockOpenApiTool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); expect(tool).toBeDefined(); }); @@ -356,11 +405,17 @@ describe('OpenapiAdapter - Tool Creation', () => { }, }; - const tool = createOpenApiTool(mockOpenApiTool, { - name: 'test-api', - baseUrl: 'https://api.example.com', - spec: {} as any, - }); + const mockLogger = createMockLogger(); + const tool = createOpenApiTool( + mockOpenApiTool, + { + name: 'test-api', + baseUrl: 'https://api.example.com', + spec: {} as any, + logger: mockLogger, + }, + mockLogger, + ); expect(tool).toBeDefined(); }); diff --git a/libs/adapters/src/openapi/__tests__/openapi-utils.spec.ts b/libs/adapters/src/openapi/__tests__/openapi-utils.spec.ts index cb81d2ff..98ad9e5d 100644 --- a/libs/adapters/src/openapi/__tests__/openapi-utils.spec.ts +++ b/libs/adapters/src/openapi/__tests__/openapi-utils.spec.ts @@ -182,7 +182,7 @@ describe('OpenapiAdapter - Utilities', () => { expect(() => { buildRequest(tool, input, security, 'https://api.example.com'); - }).toThrow(/Required parameter.*id.*is missing/); + }).toThrow(/Required.*parameter.*'id'.*is missing/); }); it('should throw error if path parameters are unresolved', () => { @@ -300,32 +300,31 @@ describe('OpenapiAdapter - Utilities', () => { text: () => Promise.resolve('not valid json{'), } as Response; - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - + // Invalid JSON should be returned as text without logging to console const result = await parseResponse(response); expect(result).toEqual({ data: 'not valid json{' }); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse JSON'), expect.any(Error)); - - consoleSpy.mockRestore(); }); it('should throw error for HTTP errors', async () => { const response = await mockFetchError(404, 'Not Found'); - await expect(parseResponse(response)).rejects.toThrow(/API request failed.*404.*Not Found/); + // Note: statusText is NOT included in error message for security (could leak sensitive info) + await expect(parseResponse(response)).rejects.toThrow(/API request failed: 404/); }); it('should throw error for 401 Unauthorized', async () => { const response = await mockFetchError(401, 'Unauthorized'); - await expect(parseResponse(response)).rejects.toThrow(/API request failed.*401.*Unauthorized/); + // Note: statusText is NOT included in error message for security (could leak sensitive info) + await expect(parseResponse(response)).rejects.toThrow(/API request failed: 401/); }); it('should throw error for 500 Internal Server Error', async () => { const response = await mockFetchError(500, 'Internal Server Error'); - await expect(parseResponse(response)).rejects.toThrow(/API request failed.*500.*Internal Server Error/); + // Note: statusText is NOT included in error message for security (could leak sensitive info) + await expect(parseResponse(response)).rejects.toThrow(/API request failed: 500/); }); }); }); diff --git a/libs/adapters/src/openapi/openapi.adapter.ts b/libs/adapters/src/openapi/openapi.adapter.ts index 3c6b23b6..9ec42c5b 100644 --- a/libs/adapters/src/openapi/openapi.adapter.ts +++ b/libs/adapters/src/openapi/openapi.adapter.ts @@ -1,20 +1,45 @@ -import { Adapter, DynamicAdapter, FrontMcpAdapterResponse } from '@frontmcp/sdk'; -import { OpenApiAdapterOptions } from './openapi.types'; -import { OpenAPIToolGenerator } from 'mcp-from-openapi'; +import { Adapter, DynamicAdapter, FrontMcpAdapterResponse, FrontMcpLogger } from '@frontmcp/sdk'; +import { OpenApiAdapterOptions, InputTransform, ToolTransform, ExtendedMcpOpenAPITool } from './openapi.types'; +import { OpenAPIToolGenerator, McpOpenAPITool } from 'mcp-from-openapi'; import { createOpenApiTool } from './openapi.tool'; import { validateSecurityConfiguration } from './openapi.security'; +/** + * Creates a simple console-based logger for use outside the SDK context. + */ +function createConsoleLogger(prefix: string): FrontMcpLogger { + const formatMessage = (level: string, msg: string) => `[${prefix}] ${level}: ${msg}`; + return { + verbose: (msg: string, ...args: unknown[]) => console.debug(formatMessage('VERBOSE', msg), ...args), + debug: (msg: string, ...args: unknown[]) => console.debug(formatMessage('DEBUG', msg), ...args), + info: (msg: string, ...args: unknown[]) => console.info(formatMessage('INFO', msg), ...args), + warn: (msg: string, ...args: unknown[]) => console.warn(formatMessage('WARN', msg), ...args), + error: (msg: string, ...args: unknown[]) => console.error(formatMessage('ERROR', msg), ...args), + child: (childPrefix: string) => createConsoleLogger(`${prefix}:${childPrefix}`), + }; +} + @Adapter({ name: 'openapi', description: 'OpenAPI adapter for FrontMCP - Automatically generates MCP tools from OpenAPI specifications', }) export default class OpenapiAdapter extends DynamicAdapter { private generator?: OpenAPIToolGenerator; + private logger: FrontMcpLogger; public options: OpenApiAdapterOptions; constructor(options: OpenApiAdapterOptions) { super(); this.options = options; + // Use provided logger or create console fallback + this.logger = options.logger ?? createConsoleLogger(`openapi:${options.name}`); + } + + /** + * Receive the SDK logger. Called by the SDK before fetch(). + */ + setLogger(logger: FrontMcpLogger): void { + this.logger = logger; } async fetch(): Promise { @@ -23,8 +48,14 @@ export default class OpenapiAdapter extends DynamicAdapter 0; + const includeSecurityInInput = + hasPerSchemeControl || (this.options.generateOptions?.includeSecurityInInput ?? false); + // Generate tools from OpenAPI spec - const openapiTools = await this.generator.generateTools({ + let openapiTools = await this.generator.generateTools({ includeOperations: this.options.generateOptions?.includeOperations, excludeOperations: this.options.generateOptions?.excludeOperations, filterFn: this.options.generateOptions?.filterFn, @@ -32,23 +63,34 @@ export default class OpenapiAdapter extends DynamicAdapter this.filterSecuritySchemes(tool)); + } + // Validate security configuration const validation = validateSecurityConfiguration(openapiTools, this.options); // Log security information - console.log(`\n[OpenAPI Adapter: ${this.options.name}] Security Analysis:`); - console.log(` Security Risk Score: ${validation.securityRiskScore.toUpperCase()}`); - console.log(` Valid Configuration: ${validation.valid ? 'YES' : 'NO'}`); + this.logger.info('Security Analysis:'); + this.logger.info(` Security Risk Score: ${validation.securityRiskScore.toUpperCase()}`); + this.logger.info(` Valid Configuration: ${validation.valid ? 'YES' : 'NO'}`); if (validation.warnings.length > 0) { - console.log('\n Messages:'); + this.logger.info('Messages:'); validation.warnings.forEach((warning) => { - console.log(` - ${warning}`); + if (warning.startsWith('ERROR:')) { + this.logger.error(` - ${warning}`); + } else if (warning.startsWith('SECURITY WARNING:')) { + this.logger.warn(` - ${warning}`); + } else { + this.logger.info(` - ${warning}`); + } }); } @@ -61,23 +103,39 @@ export default class OpenapiAdapter extends DynamicAdapter ` '${s}': (authInfo) => authInfo.user?.${s.toLowerCase()}Token,`).join('\n') + + validation.missingMappings + .map((s) => ` '${s}': (authInfo) => authInfo.user?.${s.toLowerCase()}Token,`) + .join('\n') + `\n }\n\n` + `2. securityResolver:\n` + ` securityResolver: (tool, authInfo) => ({ jwt: authInfo.token })\n\n` + `3. staticAuth:\n` + ` staticAuth: { jwt: process.env.API_TOKEN }\n\n` + `4. Include security in input (NOT recommended for production):\n` + - ` generateOptions: { includeSecurityInInput: true }` + ` generateOptions: { includeSecurityInInput: true }`, ); } - console.log(''); // Empty line for readability + // Apply all transforms to tools + let transformedTools = openapiTools; + + // 1. Apply description mode (generates description from summary/description) + if (this.options.descriptionMode && this.options.descriptionMode !== 'summaryOnly') { + transformedTools = transformedTools.map((tool) => this.applyDescriptionMode(tool)); + } + + // 2. Apply tool transforms (annotations, name, description overrides, etc.) + if (this.options.toolTransforms) { + transformedTools = transformedTools.map((tool) => this.applyToolTransforms(tool)); + } + + // 3. Apply input transforms (hide inputs, inject values at runtime) + if (this.options.inputTransforms) { + transformedTools = transformedTools.map((tool) => this.applyInputTransforms(tool)); + } // Convert OpenAPI tools to FrontMCP tools - const tools = openapiTools.map((openapiTool) => - createOpenApiTool(openapiTool, this.options) - ); + const tools = transformedTools.map((openapiTool) => createOpenApiTool(openapiTool, this.options, this.logger)); return { tools }; } @@ -106,4 +164,288 @@ export default class OpenapiAdapter extends DynamicAdapter; + const summary = metadata['operationSummary'] as string | undefined; + const opDescription = metadata['operationDescription'] as string | undefined; + const operationId = metadata['operationId'] as string | undefined; + const method = metadata['method'] as string; + const path = metadata['path'] as string; + + let description: string; + + switch (mode) { + case 'descriptionOnly': + description = opDescription || summary || `${method.toUpperCase()} ${path}`; + break; + case 'combined': + if (summary && opDescription) { + description = `${summary}\n\n${opDescription}`; + } else { + description = summary || opDescription || `${method.toUpperCase()} ${path}`; + } + break; + case 'full': + const parts: string[] = []; + if (summary) parts.push(summary); + if (opDescription && opDescription !== summary) parts.push(opDescription); + if (operationId) parts.push(`Operation: ${operationId}`); + parts.push(`${method.toUpperCase()} ${path}`); + description = parts.join('\n\n'); + break; + default: + // 'summaryOnly' - use existing description + return tool; + } + + return { + ...tool, + description, + }; + } + + /** + * Collect tool transforms for a specific tool + * @private + */ + private collectToolTransforms(tool: McpOpenAPITool): ToolTransform { + const result: ToolTransform = {}; + const opts = this.options.toolTransforms; + if (!opts) return result; + + // 1. Apply global transforms + if (opts.global) { + Object.assign(result, opts.global); + if (opts.global.annotations) { + result.annotations = { ...opts.global.annotations }; + } + if (opts.global.tags) { + result.tags = [...opts.global.tags]; + } + if (opts.global.examples) { + result.examples = [...opts.global.examples]; + } + } + + // 2. Apply per-tool transforms (override global) + if (opts.perTool?.[tool.name]) { + const perTool = opts.perTool[tool.name]; + if (perTool.name) result.name = perTool.name; + if (perTool.description) result.description = perTool.description; + if (perTool.hideFromDiscovery !== undefined) result.hideFromDiscovery = perTool.hideFromDiscovery; + if (perTool.ui) result.ui = perTool.ui; + if (perTool.annotations) { + result.annotations = { ...result.annotations, ...perTool.annotations }; + } + if (perTool.tags) { + result.tags = [...(result.tags || []), ...perTool.tags]; + } + if (perTool.examples) { + result.examples = [...(result.examples || []), ...perTool.examples]; + } + } + + // 3. Apply generator-produced transforms (override per-tool) + if (opts.generator) { + const generated = opts.generator(tool); + if (generated) { + if (generated.name) result.name = generated.name; + if (generated.description) result.description = generated.description; + if (generated.hideFromDiscovery !== undefined) result.hideFromDiscovery = generated.hideFromDiscovery; + if (generated.ui) result.ui = generated.ui; + if (generated.annotations) { + result.annotations = { ...result.annotations, ...generated.annotations }; + } + if (generated.tags) { + result.tags = [...(result.tags || []), ...generated.tags]; + } + if (generated.examples) { + result.examples = [...(result.examples || []), ...generated.examples]; + } + } + } + + return result; + } + + /** + * Apply tool transforms to an OpenAPI tool + * @private + */ + private applyToolTransforms(tool: McpOpenAPITool): McpOpenAPITool { + const transforms = this.collectToolTransforms(tool); + if (Object.keys(transforms).length === 0) return tool; + + let newName = tool.name; + let newDescription = tool.description; + + // Apply name transform + if (transforms.name) { + newName = typeof transforms.name === 'function' ? transforms.name(tool.name, tool) : transforms.name; + } + + // Apply description transform + if (transforms.description) { + newDescription = + typeof transforms.description === 'function' + ? transforms.description(tool.description, tool) + : transforms.description; + } + + this.logger.debug(`Applied tool transforms to '${tool.name}'`); + + return { + ...tool, + name: newName, + description: newDescription, + metadata: { + ...tool.metadata, + adapter: { + ...((tool.metadata as any).adapter || {}), + toolTransform: transforms, + }, + }, + } as ExtendedMcpOpenAPITool; + } + + /** + * Collect all input transforms for a specific tool + * @private + */ + private collectTransformsForTool(tool: McpOpenAPITool): InputTransform[] { + const transforms: InputTransform[] = []; + const opts = this.options.inputTransforms; + if (!opts) return transforms; + + // 1. Add global transforms + if (opts.global) { + transforms.push(...opts.global); + } + + // 2. Add per-tool transforms + if (opts.perTool?.[tool.name]) { + transforms.push(...opts.perTool[tool.name]); + } + + // 3. Add generator-produced transforms + if (opts.generator) { + transforms.push(...opts.generator(tool)); + } + + return transforms; + } + + /** + * Apply input transforms to an OpenAPI tool + * - Removes transformed inputKeys from the inputSchema + * - Stores transform metadata for runtime injection + * @private + */ + private applyInputTransforms(tool: McpOpenAPITool): McpOpenAPITool { + const transforms = this.collectTransformsForTool(tool); + if (transforms.length === 0) return tool; + + const transformedInputKeys = new Set(transforms.map((t) => t.inputKey)); + + // Clone and modify inputSchema to remove transformed keys + const inputSchema = tool.inputSchema as Record; + const properties = (inputSchema?.['properties'] as Record) || {}; + const required = (inputSchema?.['required'] as string[]) || []; + + // Remove transformed keys from properties + const newProperties = { ...properties }; + for (const key of transformedInputKeys) { + delete newProperties[key]; + } + + // Update required array to exclude transformed keys + const newRequired = required.filter((key) => !transformedInputKeys.has(key)); + + this.logger.debug(`Applied ${transforms.length} input transforms to tool '${tool.name}'`); + + return { + ...tool, + inputSchema: { + ...inputSchema, + properties: newProperties, + ...(newRequired.length > 0 ? { required: newRequired } : {}), + }, + // Store transforms in metadata for runtime use + metadata: { + ...tool.metadata, + adapter: { + ...((tool.metadata as any).adapter || {}), + inputTransforms: transforms, + }, + }, + } as ExtendedMcpOpenAPITool; + } + + /** + * Filter security schemes in tool input based on securitySchemesInInput option. + * Removes security inputs that should be resolved from context instead of user input. + * @private + */ + private filterSecuritySchemes(tool: McpOpenAPITool): McpOpenAPITool { + const allowedSchemes = new Set(this.options.securitySchemesInInput || []); + if (allowedSchemes.size === 0) return tool; + + // Find security mappers that should NOT be in input (resolved from context) + const schemesToRemove = new Set(); + const inputKeysToRemove = new Set(); + + for (const mapper of tool.mapper) { + if (mapper.security?.scheme && !allowedSchemes.has(mapper.security.scheme)) { + schemesToRemove.add(mapper.security.scheme); + inputKeysToRemove.add(mapper.inputKey); + } + } + + if (inputKeysToRemove.size === 0) return tool; + + // Remove security inputs from inputSchema + const inputSchema = tool.inputSchema as Record; + const properties = (inputSchema?.['properties'] as Record) || {}; + const required = (inputSchema?.['required'] as string[]) || []; + + const newProperties = { ...properties }; + for (const key of inputKeysToRemove) { + delete newProperties[key]; + } + + const newRequired = required.filter((key) => !inputKeysToRemove.has(key)); + + this.logger.debug( + `[${tool.name}] Filtered security schemes from input: ${Array.from(schemesToRemove).join(', ')}. ` + + `Kept in input: ${ + Array.from(allowedSchemes) + .filter((s) => !schemesToRemove.has(s)) + .join(', ') || 'none' + }`, + ); + + return { + ...tool, + inputSchema: { + ...inputSchema, + properties: newProperties, + ...(newRequired.length > 0 ? { required: newRequired } : {}), + }, + // Store which security schemes are in input vs context for later resolution + metadata: { + ...tool.metadata, + adapter: { + ...((tool.metadata as any).adapter || {}), + securitySchemesInInput: Array.from(allowedSchemes), + securitySchemesFromContext: Array.from(schemesToRemove), + }, + }, + } as ExtendedMcpOpenAPITool; + } } diff --git a/libs/adapters/src/openapi/openapi.frontmcp-schema.ts b/libs/adapters/src/openapi/openapi.frontmcp-schema.ts new file mode 100644 index 00000000..50ca7129 --- /dev/null +++ b/libs/adapters/src/openapi/openapi.frontmcp-schema.ts @@ -0,0 +1,451 @@ +/** + * Zod schemas for x-frontmcp OpenAPI extension validation. + * + * The x-frontmcp extension allows embedding FrontMCP-specific configuration + * directly in OpenAPI specs. This module provides versioned schema validation + * to ensure only valid data is used and invalid fields are warned about. + */ + +import { z } from 'zod'; +import type { FrontMcpLogger } from '@frontmcp/sdk'; + +/** + * Current schema version for x-frontmcp extension. + * Increment when making breaking changes to the schema. + */ +export const FRONTMCP_SCHEMA_VERSION = '1.0' as const; + +/** + * Supported schema versions. + */ +export const SUPPORTED_VERSIONS = ['1.0'] as const; +export type FrontMcpSchemaVersion = (typeof SUPPORTED_VERSIONS)[number]; + +// ============================================================================ +// Annotations Schema (v1.0) +// ============================================================================ + +/** + * Tool annotations schema - hints about tool behavior for AI clients. + */ +export const FrontMcpAnnotationsSchema = z.object({ + /** Human-readable title for the tool */ + title: z.string().optional(), + /** If true, the tool does not modify its environment */ + readOnlyHint: z.boolean().optional(), + /** If true, the tool may perform destructive updates */ + destructiveHint: z.boolean().optional(), + /** If true, calling repeatedly with same args has no additional effect */ + idempotentHint: z.boolean().optional(), + /** If true, tool may interact with external entities */ + openWorldHint: z.boolean().optional(), +}); + +export type FrontMcpAnnotations = z.infer; + +// ============================================================================ +// Cache Schema (v1.0) +// ============================================================================ + +/** + * Cache configuration schema. + */ +export const FrontMcpCacheSchema = z.object({ + /** Time-to-live in seconds for cached responses */ + ttl: z.number().int().positive().optional(), + /** If true, cache window slides on each access */ + slideWindow: z.boolean().optional(), +}); + +export type FrontMcpCache = z.infer; + +// ============================================================================ +// CodeCall Schema (v1.0) +// ============================================================================ + +/** + * CodeCall plugin configuration schema. + */ +export const FrontMcpCodeCallSchema = z.object({ + /** Whether this tool can be used via CodeCall */ + enabledInCodeCall: z.boolean().optional(), + /** If true, stays visible in list_tools when CodeCall is active */ + visibleInListTools: z.boolean().optional(), +}); + +export type FrontMcpCodeCall = z.infer; + +// ============================================================================ +// Example Schema (v1.0) +// ============================================================================ + +/** + * Tool usage example schema. + */ +export const FrontMcpExampleSchema = z.object({ + /** Description of the example */ + description: z.string(), + /** Example input values */ + input: z.record(z.string(), z.any()), + /** Expected output (optional) */ + output: z.any().optional(), +}); + +export type FrontMcpExample = z.infer; + +// ============================================================================ +// Known Fields for Validation +// ============================================================================ + +/** Known field names for validation */ +const KNOWN_EXTENSION_FIELDS = new Set([ + 'version', + 'annotations', + 'cache', + 'codecall', + 'tags', + 'hideFromDiscovery', + 'examples', +]); + +const KNOWN_ANNOTATION_FIELDS = new Set([ + 'title', + 'readOnlyHint', + 'destructiveHint', + 'idempotentHint', + 'openWorldHint', +]); + +const KNOWN_CACHE_FIELDS = new Set(['ttl', 'slideWindow']); +const KNOWN_CODECALL_FIELDS = new Set(['enabledInCodeCall', 'visibleInListTools']); + +// ============================================================================ +// Validated Extension Type (after parsing) +// ============================================================================ + +/** + * Validated x-frontmcp extension data. + * This is the output after schema validation. + */ +export interface ValidatedFrontMcpExtension { + version: FrontMcpSchemaVersion; + annotations?: FrontMcpAnnotations; + cache?: FrontMcpCache; + codecall?: FrontMcpCodeCall; + tags?: string[]; + hideFromDiscovery?: boolean; + examples?: FrontMcpExample[]; +} + +// ============================================================================ +// Validation Result +// ============================================================================ + +export interface FrontMcpValidationResult { + /** Whether validation succeeded (may still have warnings) */ + success: boolean; + /** Validated data (only valid fields) */ + data: ValidatedFrontMcpExtension | null; + /** Validation warnings (invalid fields that were ignored) */ + warnings: string[]; +} + +// ============================================================================ +// Schema Validation Functions +// ============================================================================ + +/** + * Validate and parse x-frontmcp extension data. + * + * This function: + * 1. Detects the schema version (defaults to current) + * 2. Validates each field against the appropriate schema + * 3. Returns only valid fields, ignoring invalid ones + * 4. Collects warnings for invalid/unknown fields + * + * @param rawData - Raw x-frontmcp data from OpenAPI spec + * @param toolName - Tool name for error context + * @param logger - Logger for warnings + * @returns Validation result with valid data and warnings + */ +export function validateFrontMcpExtension( + rawData: unknown, + toolName: string, + logger: FrontMcpLogger, +): FrontMcpValidationResult { + const warnings: string[] = []; + + // Handle null/undefined + if (rawData === null || rawData === undefined) { + return { success: true, data: null, warnings: [] }; + } + + // Must be an object + if (typeof rawData !== 'object' || Array.isArray(rawData)) { + warnings.push(`x-frontmcp must be an object, got ${Array.isArray(rawData) ? 'array' : typeof rawData}`); + logger.warn(`[${toolName}] Invalid x-frontmcp extension: ${warnings[0]}`); + return { success: false, data: null, warnings }; + } + + const data = rawData as Record; + + // Detect version + const version = typeof data['version'] === 'string' ? data['version'] : FRONTMCP_SCHEMA_VERSION; + + // Check if version is supported + if (!SUPPORTED_VERSIONS.includes(version as FrontMcpSchemaVersion)) { + warnings.push(`Unsupported x-frontmcp version '${version}'. Supported versions: ${SUPPORTED_VERSIONS.join(', ')}`); + logger.warn(`[${toolName}] ${warnings[0]}`); + return { success: false, data: null, warnings }; + } + + // Extract valid fields and collect warnings + const validData = extractValidFields(data, version, warnings); + + if (warnings.length > 0) { + logger.warn(`[${toolName}] x-frontmcp extension has invalid fields that were ignored:`); + warnings.forEach((w) => logger.warn(` - ${w}`)); + } + + return { + success: true, + data: validData, + warnings, + }; +} + +/** + * Extract valid fields from x-frontmcp data, collecting warnings for invalid fields. + */ +function extractValidFields( + data: Record, + version: string, + warnings: string[], +): ValidatedFrontMcpExtension { + const result: ValidatedFrontMcpExtension = { + version: version as FrontMcpSchemaVersion, + }; + + // Warn about unknown top-level fields + for (const key of Object.keys(data)) { + if (!KNOWN_EXTENSION_FIELDS.has(key)) { + warnings.push(`Unknown field '${key}' in x-frontmcp (will be ignored)`); + } + } + + // Validate annotations + if (data['annotations'] !== undefined) { + const annotationsResult = FrontMcpAnnotationsSchema.safeParse(data['annotations']); + if (annotationsResult.success) { + result.annotations = annotationsResult.data; + } else { + const issues = formatZodIssues(annotationsResult.error.issues, 'annotations'); + warnings.push(...issues); + // Try to extract valid annotation fields + result.annotations = extractValidAnnotations(data['annotations'], warnings); + } + } + + // Validate cache + if (data['cache'] !== undefined) { + const cacheResult = FrontMcpCacheSchema.safeParse(data['cache']); + if (cacheResult.success) { + result.cache = cacheResult.data; + } else { + const issues = formatZodIssues(cacheResult.error.issues, 'cache'); + warnings.push(...issues); + // Try to extract valid cache fields + result.cache = extractValidCache(data['cache'], warnings); + } + } + + // Validate codecall + if (data['codecall'] !== undefined) { + const codecallResult = FrontMcpCodeCallSchema.safeParse(data['codecall']); + if (codecallResult.success) { + result.codecall = codecallResult.data; + } else { + const issues = formatZodIssues(codecallResult.error.issues, 'codecall'); + warnings.push(...issues); + // Try to extract valid codecall fields + result.codecall = extractValidCodeCall(data['codecall'], warnings); + } + } + + // Validate tags + if (data['tags'] !== undefined) { + const tagsSchema = z.array(z.string()); + const tagsResult = tagsSchema.safeParse(data['tags']); + if (tagsResult.success) { + result.tags = tagsResult.data; + } else { + warnings.push(`Invalid 'tags': expected array of strings`); + // Try to extract valid tags + result.tags = extractValidTags(data['tags']); + } + } + + // Validate hideFromDiscovery + if (data['hideFromDiscovery'] !== undefined) { + if (typeof data['hideFromDiscovery'] === 'boolean') { + result.hideFromDiscovery = data['hideFromDiscovery']; + } else { + warnings.push(`Invalid 'hideFromDiscovery': expected boolean, got ${typeof data['hideFromDiscovery']}`); + } + } + + // Validate examples + if (data['examples'] !== undefined) { + const examplesSchema = z.array(FrontMcpExampleSchema); + const examplesResult = examplesSchema.safeParse(data['examples']); + if (examplesResult.success) { + result.examples = examplesResult.data; + } else { + warnings.push(`Invalid 'examples': some examples have invalid format`); + // Try to extract valid examples + result.examples = extractValidExamples(data['examples'], warnings); + } + } + + return result; +} + +/** + * Extract valid annotation fields. + */ +function extractValidAnnotations(data: unknown, warnings: string[]): FrontMcpAnnotations | undefined { + if (typeof data !== 'object' || data === null) return undefined; + + const obj = data as Record; + const result: FrontMcpAnnotations = {}; + + for (const field of KNOWN_ANNOTATION_FIELDS) { + if (obj[field] !== undefined) { + if (field === 'title' && typeof obj[field] === 'string') { + result.title = obj[field] as string; + } else if (field !== 'title' && typeof obj[field] === 'boolean') { + (result as Record)[field] = obj[field]; + } + } + } + + // Warn about unknown annotation fields + for (const key of Object.keys(obj)) { + if (!KNOWN_ANNOTATION_FIELDS.has(key)) { + warnings.push(`Unknown field 'annotations.${key}' (will be ignored)`); + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Extract valid cache fields. + */ +function extractValidCache(data: unknown, warnings: string[]): FrontMcpCache | undefined { + if (typeof data !== 'object' || data === null) return undefined; + + const obj = data as Record; + const result: FrontMcpCache = {}; + + if (typeof obj['ttl'] === 'number' && Number.isInteger(obj['ttl']) && obj['ttl'] > 0) { + result.ttl = obj['ttl']; + } else if (obj['ttl'] !== undefined) { + warnings.push(`Invalid 'cache.ttl': expected positive integer`); + } + + if (typeof obj['slideWindow'] === 'boolean') { + result.slideWindow = obj['slideWindow']; + } else if (obj['slideWindow'] !== undefined) { + warnings.push(`Invalid 'cache.slideWindow': expected boolean`); + } + + // Warn about unknown cache fields + for (const key of Object.keys(obj)) { + if (!KNOWN_CACHE_FIELDS.has(key)) { + warnings.push(`Unknown field 'cache.${key}' (will be ignored)`); + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Extract valid codecall fields. + */ +function extractValidCodeCall(data: unknown, warnings: string[]): FrontMcpCodeCall | undefined { + if (typeof data !== 'object' || data === null) return undefined; + + const obj = data as Record; + const result: FrontMcpCodeCall = {}; + + if (typeof obj['enabledInCodeCall'] === 'boolean') { + result.enabledInCodeCall = obj['enabledInCodeCall']; + } else if (obj['enabledInCodeCall'] !== undefined) { + warnings.push(`Invalid 'codecall.enabledInCodeCall': expected boolean`); + } + + if (typeof obj['visibleInListTools'] === 'boolean') { + result.visibleInListTools = obj['visibleInListTools']; + } else if (obj['visibleInListTools'] !== undefined) { + warnings.push(`Invalid 'codecall.visibleInListTools': expected boolean`); + } + + // Warn about unknown codecall fields + for (const key of Object.keys(obj)) { + if (!KNOWN_CODECALL_FIELDS.has(key)) { + warnings.push(`Unknown field 'codecall.${key}' (will be ignored)`); + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Extract valid tags from array. + */ +function extractValidTags(data: unknown): string[] | undefined { + if (!Array.isArray(data)) return undefined; + + const validTags = data.filter((item): item is string => typeof item === 'string'); + return validTags.length > 0 ? validTags : undefined; +} + +/** + * Extract valid examples from array. + */ +function extractValidExamples(data: unknown, warnings: string[]): FrontMcpExample[] | undefined { + if (!Array.isArray(data)) return undefined; + + const validExamples: FrontMcpExample[] = []; + + for (let i = 0; i < data.length; i++) { + const item = data[i]; + const result = FrontMcpExampleSchema.safeParse(item); + if (result.success) { + validExamples.push(result.data); + } else { + warnings.push( + `Invalid example at index ${i}: ${formatZodIssues(result.error.issues, `examples[${i}]`).join(', ')}`, + ); + } + } + + return validExamples.length > 0 ? validExamples : undefined; +} + +/** Zod issue shape for formatting */ +interface ZodIssue { + path: PropertyKey[]; + message: string; +} + +/** + * Format Zod validation issues into readable messages. + */ +function formatZodIssues(issues: ZodIssue[], prefix: string): string[] { + return issues.map((issue) => { + const path = issue.path.length > 0 ? `${prefix}.${issue.path.join('.')}` : prefix; + return `Invalid '${path}': ${issue.message}`; + }); +} diff --git a/libs/adapters/src/openapi/openapi.security.ts b/libs/adapters/src/openapi/openapi.security.ts index dc7d4dca..a0615f4b 100644 --- a/libs/adapters/src/openapi/openapi.security.ts +++ b/libs/adapters/src/openapi/openapi.security.ts @@ -1,9 +1,4 @@ -import { - SecurityResolver, - createSecurityContext, - type McpOpenAPITool, - type SecurityContext, -} from 'mcp-from-openapi'; +import { SecurityResolver, createSecurityContext, type McpOpenAPITool, type SecurityContext } from 'mcp-from-openapi'; import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; import type { OpenApiAdapterOptions } from './openapi.types'; @@ -39,7 +34,7 @@ export interface SecurityValidationResult { export async function createSecurityContextFromAuth( tool: McpOpenAPITool, authInfo: AuthInfo, - options: Pick + options: Pick, ): Promise { // 1. Use custom security resolver if provided (highest priority) if (options.securityResolver) { @@ -59,21 +54,77 @@ export async function createSecurityContextFromAuth( } // Map each security scheme to its auth provider + // Process all schemes - first matching token is used for jwt for (const scheme of securitySchemes) { const authExtractor = options.authProviderMapper[scheme]; if (authExtractor) { - const token = authExtractor(authInfo); - if (token) { - // Store in jwt field for http bearer auth - // For other auth types, you can extend this logic - context.jwt = token; - break; // Use first matching provider + try { + const token = authExtractor(authInfo); + + // Validate return type - must be string or undefined/null + if (token !== undefined && token !== null && typeof token !== 'string') { + throw new Error( + `authProviderMapper['${scheme}'] must return a string or undefined, ` + `but returned: ${typeof token}`, + ); + } + + // Reject empty string tokens explicitly - indicates misconfiguration + if (token === '') { + throw new Error( + `authProviderMapper['${scheme}'] returned empty string. ` + + `Return undefined/null if no token is available, or provide a valid token.`, + ); + } + + if (token) { + // Route token to correct context field based on scheme type + // Look up the scheme info from the mapper to determine type + const schemeMapper = tool.mapper.find((m) => m.security?.scheme === scheme); + const schemeType = schemeMapper?.security?.type?.toLowerCase(); + const httpScheme = schemeMapper?.security?.httpScheme?.toLowerCase(); + + // Route based on security scheme type (first token for each type wins) + if (schemeType === 'apikey') { + if (!context.apiKey) { + context.apiKey = token; + } + } else if (schemeType === 'http' && httpScheme === 'basic') { + if (!context.basic) { + context.basic = token; + } + } else if (schemeType === 'oauth2') { + if (!context.oauth2Token) { + context.oauth2Token = token; + } + } else { + // Default to jwt for http bearer and unknown types + if (!context.jwt) { + context.jwt = token; + } + } + // Continue checking other schemes - don't break + // This allows validation to see all configured providers + } + } catch (err) { + // Re-throw validation errors as-is + if (err instanceof Error && err.message.includes('authProviderMapper')) { + throw err; + } + // Wrap other errors with context + const errorMessage = err instanceof Error ? err.message : String(err); + throw new Error(`authProviderMapper['${scheme}'] threw an error: ${errorMessage}`); } } } - // If no provider matched but we have a default token, use it - if (!context.jwt && authInfo.token) { + // If no auth was set from providers, fall back to authInfo.token + // Only fall back if ALL auth fields are empty (not just jwt) + const hasAnyAuth = context.jwt || context.apiKey || context.basic || context.oauth2Token; + if (!hasAnyAuth && authInfo.token) { + // Validate type before assignment to prevent non-string values + if (typeof authInfo.token !== 'string') { + throw new Error(`authInfo.token must be a string, but got: ${typeof authInfo.token}`); + } context.jwt = authInfo.token; } @@ -122,8 +173,8 @@ export function validateSecurityConfiguration( tools: McpOpenAPITool[], options: Pick< OpenApiAdapterOptions, - 'securityResolver' | 'authProviderMapper' | 'staticAuth' | 'generateOptions' - > + 'securityResolver' | 'authProviderMapper' | 'staticAuth' | 'generateOptions' | 'securitySchemesInInput' + >, ): SecurityValidationResult { const result: SecurityValidationResult = { valid: true, @@ -141,7 +192,7 @@ export function validateSecurityConfiguration( if (includeSecurityInInput) { result.securityRiskScore = 'high'; result.warnings.push( - 'SECURITY WARNING: includeSecurityInInput is enabled. Users will provide authentication directly in tool inputs. This increases security risk as credentials may be logged or exposed.' + 'SECURITY WARNING: includeSecurityInInput is enabled. Users will provide authentication directly in tool inputs. This increases security risk as credentials may be logged or exposed.', ); // Don't validate mappings if security is in input return result; @@ -151,7 +202,7 @@ export function validateSecurityConfiguration( if (options.securityResolver) { result.securityRiskScore = 'low'; result.warnings.push( - 'INFO: Using custom securityResolver. Ensure your resolver properly validates and secures credentials from context.' + 'INFO: Using custom securityResolver. Ensure your resolver properly validates and secures credentials from context.', ); return result; } @@ -160,19 +211,34 @@ export function validateSecurityConfiguration( if (options.staticAuth && Object.keys(options.staticAuth).length > 0) { result.securityRiskScore = 'medium'; result.warnings.push( - 'SECURITY INFO: Using staticAuth with hardcoded credentials. Ensure credentials are stored securely (environment variables, secrets manager).' + 'SECURITY INFO: Using staticAuth with hardcoded credentials. Ensure credentials are stored securely (environment variables, secrets manager).', ); // If static auth is provided, assume it covers all schemes return result; } + // Get schemes that will be provided via input (don't need mapping) + const schemesInInput = new Set(options.securitySchemesInInput || []); + // Check authProviderMapper (low risk - context-based auth) - if (options.authProviderMapper) { - result.securityRiskScore = 'low'; + if (options.authProviderMapper || schemesInInput.size > 0) { + result.securityRiskScore = schemesInInput.size > 0 ? 'medium' : 'low'; + + // Log info about per-scheme control + if (schemesInInput.size > 0) { + result.warnings.push( + `INFO: Per-scheme security control enabled. Schemes in input: ${Array.from(schemesInInput).join(', ')}`, + ); + } - // Validate that all schemes have mappings + // Validate that all schemes have mappings (except those in input) for (const scheme of securitySchemes) { - if (!options.authProviderMapper[scheme]) { + // Skip schemes that will be provided via input + if (schemesInInput.has(scheme)) { + continue; + } + // Check if there's a mapping for this scheme + if (!options.authProviderMapper?.[scheme]) { result.valid = false; result.missingMappings.push(scheme); } @@ -180,7 +246,7 @@ export function validateSecurityConfiguration( if (!result.valid) { result.warnings.push( - `ERROR: Missing auth provider mappings for security schemes: ${result.missingMappings.join(', ')}` + `ERROR: Missing auth provider mappings for security schemes: ${result.missingMappings.join(', ')}`, ); } @@ -192,10 +258,12 @@ export function validateSecurityConfiguration( if (securitySchemes.size > 0) { result.securityRiskScore = 'medium'; result.warnings.push( - `INFO: No auth configuration provided. Using default ctx.authInfo.token for all security schemes: ${Array.from(securitySchemes).join(', ')}` + `INFO: No auth configuration provided. Using default ctx.authInfo.token for all security schemes: ${Array.from( + securitySchemes, + ).join(', ')}`, ); result.warnings.push( - 'RECOMMENDATION: For multiple auth providers, use authProviderMapper or securityResolver to map each security scheme to the correct auth provider.' + 'RECOMMENDATION: For multiple auth providers, use authProviderMapper or securityResolver to map each security scheme to the correct auth provider.', ); } @@ -214,7 +282,7 @@ export function validateSecurityConfiguration( export async function resolveToolSecurity( tool: McpOpenAPITool, authInfo: AuthInfo, - options: Pick + options: Pick, ) { const securityResolver = new SecurityResolver(); const securityContext = await createSecurityContextFromAuth(tool, authInfo, options); @@ -229,23 +297,29 @@ export async function resolveToolSecurity( (securityContext.customHeaders && Object.keys(securityContext.customHeaders).length > 0); // Check if this tool requires security - const requiresSecurity = tool.mapper.some((m) => m.security && m.required); + // A tool requires security ONLY if a mapper has security with required=true + // Optional security schemes (required=false or undefined) should not block requests + const hasSecurityScheme = tool.mapper.some((m) => m.security); + const hasRequiredSecurity = tool.mapper.some((m) => m.security && m.required === true); + const requiresSecurity = hasRequiredSecurity; if (requiresSecurity && !hasAuth) { - // Extract security scheme names - const schemes = tool.mapper - .filter((m) => m.security && m.required) - .map((m) => m.security?.scheme ?? 'unknown') - .join(', '); + // Extract required security scheme names for error message + const requiredSchemes = tool.mapper + .filter((m) => m.security && m.required === true) + .map((m) => m.security?.scheme ?? 'unknown'); + const uniqueSchemes = [...new Set(requiredSchemes)]; + const schemesStr = uniqueSchemes.join(', ') || 'unknown'; + const firstScheme = uniqueSchemes[0] || 'BearerAuth'; throw new Error( `Authentication required for tool '${tool.name}' but no auth configuration found.\n` + - `Required security schemes: ${schemes}\n` + + `Required security schemes: ${schemesStr}\n` + `Solutions:\n` + - ` 1. Add authProviderMapper: { '${schemes.split(',')[0].trim()}': (authInfo) => authInfo.user?.token }\n` + + ` 1. Add authProviderMapper: { '${firstScheme}': (authInfo) => authInfo.user?.token }\n` + ` 2. Add securityResolver: (tool, authInfo) => ({ jwt: authInfo.token })\n` + ` 3. Add staticAuth: { jwt: process.env.API_TOKEN }\n` + - ` 4. Set generateOptions.includeSecurityInInput: true (not recommended for production)` + ` 4. Set generateOptions.includeSecurityInInput: true (not recommended for production)`, ); } diff --git a/libs/adapters/src/openapi/openapi.tool.ts b/libs/adapters/src/openapi/openapi.tool.ts index 230b5c0a..4f5308e4 100644 --- a/libs/adapters/src/openapi/openapi.tool.ts +++ b/libs/adapters/src/openapi/openapi.tool.ts @@ -1,83 +1,316 @@ import { z } from 'zod'; -import { tool } from '@frontmcp/sdk'; +import { tool, FrontMcpLogger } from '@frontmcp/sdk'; import { convertJsonSchemaToZod } from 'zod-from-json-schema'; import type { McpOpenAPITool } from 'mcp-from-openapi'; -import type { OpenApiAdapterOptions } from './openapi.types'; +import type { + OpenApiAdapterOptions, + InputTransformContext, + ExtendedToolMetadata, + InputTransform, +} from './openapi.types'; import type { JSONSchema } from 'zod/v4/core'; /** JSON Schema type from Zod v4 */ type JsonSchema = JSONSchema.JSONSchema; import { buildRequest, applyAdditionalHeaders, parseResponse } from './openapi.utils'; import { resolveToolSecurity } from './openapi.security'; +import { validateFrontMcpExtension, type ValidatedFrontMcpExtension } from './openapi.frontmcp-schema'; /** * Create a FrontMCP tool from an OpenAPI tool definition * * @param openapiTool - OpenAPI tool with mapper * @param options - Adapter options + * @param logger - Logger instance * @returns FrontMCP tool */ -export function createOpenApiTool(openapiTool: McpOpenAPITool, options: OpenApiAdapterOptions) { +export function createOpenApiTool(openapiTool: McpOpenAPITool, options: OpenApiAdapterOptions, logger: FrontMcpLogger) { + // Cast metadata to extended type (includes adapter-added fields) + const metadata = openapiTool.metadata as ExtendedToolMetadata; + + // Get transforms stored in metadata by adapter + const inputTransforms = metadata.adapter?.inputTransforms ?? []; + const toolTransform = metadata.adapter?.toolTransform ?? {}; + + // Validate and parse x-frontmcp extension from OpenAPI spec + const frontmcpValidation = validateFrontMcpExtension(metadata.frontmcp, openapiTool.name, logger); + const frontmcpExt: ValidatedFrontMcpExtension | null = frontmcpValidation.data; + // Convert JSON Schema to Zod schema for input validation - const inputSchema = getZodSchemaFromJsonSchema(openapiTool.inputSchema, openapiTool.name); + const schemaResult = getZodSchemaFromJsonSchema(openapiTool.inputSchema, openapiTool.name, logger); - return tool({ + // Build tool metadata with transforms applied + // Priority: OpenAPI x-frontmcp → toolTransforms (adapter can override spec) + const toolMetadata: Record = { id: openapiTool.name, name: openapiTool.name, description: openapiTool.description, - inputSchema: inputSchema.shape || {}, + inputSchema: schemaResult.schema.shape || {}, rawInputSchema: openapiTool.inputSchema, - })(async (input, ctx) => { - // 1. Resolve security from context + }; + + // Track schema conversion failure in metadata for debugging + if (schemaResult.conversionFailed) { + toolMetadata['_schemaConversionFailed'] = true; + toolMetadata['_schemaConversionError'] = schemaResult.error; + } + + // 1. Apply validated x-frontmcp extensions from OpenAPI spec (base layer) + if (frontmcpExt) { + if (frontmcpExt.annotations) { + toolMetadata['annotations'] = { ...frontmcpExt.annotations }; + } + if (frontmcpExt.tags) { + toolMetadata['tags'] = [...frontmcpExt.tags]; + } + if (frontmcpExt.hideFromDiscovery !== undefined) { + toolMetadata['hideFromDiscovery'] = frontmcpExt.hideFromDiscovery; + } + if (frontmcpExt.examples) { + toolMetadata['examples'] = [...frontmcpExt.examples]; + } + if (frontmcpExt.cache) { + toolMetadata['cache'] = { ...frontmcpExt.cache }; + } + if (frontmcpExt.codecall) { + toolMetadata['codecall'] = { ...frontmcpExt.codecall }; + } + } + + // 2. Apply toolTransforms (adapter-level overrides) + if (toolTransform.annotations) { + toolMetadata['annotations'] = { + ...((toolMetadata['annotations'] as object) || {}), + ...toolTransform.annotations, + }; + } + if (toolTransform.tags) { + const existingTags = (toolMetadata['tags'] as string[]) || []; + toolMetadata['tags'] = [...existingTags, ...toolTransform.tags]; + } + if (toolTransform.hideFromDiscovery !== undefined) { + toolMetadata['hideFromDiscovery'] = toolTransform.hideFromDiscovery; + } + if (toolTransform.examples) { + const existingExamples = (toolMetadata['examples'] as unknown[]) || []; + toolMetadata['examples'] = [...existingExamples, ...toolTransform.examples]; + } + if (toolTransform.ui) { + toolMetadata['ui'] = toolTransform.ui; + } + + return tool(toolMetadata as unknown as Parameters[0])(async (input, ctx) => { + // 1. Inject transformed values (from inputTransforms) + const transformContext: InputTransformContext = { + authInfo: ctx.authInfo, + env: process.env, + tool: openapiTool, + }; + const injectedInput = await injectTransformedValues( + input as Record, + inputTransforms, + transformContext, + ); + + // 2. Resolve security from context const security = await resolveToolSecurity(openapiTool, ctx.authInfo, options); - // 2. Build request from mapper - const { url, headers, body: requestBody } = buildRequest(openapiTool, input, security, options.baseUrl); + // 3. Build request from mapper (now uses injectedInput) + const { url, headers, body: requestBody } = buildRequest(openapiTool, injectedInput, security, options.baseUrl); - // 3. Apply additional headers + // 4. Apply additional headers applyAdditionalHeaders(headers, options.additionalHeaders); - // 4. Apply custom headers mapper + // 5. Apply custom headers mapper with error handling if (options.headersMapper) { - const mappedHeaders = options.headersMapper(ctx.authInfo, headers); - mappedHeaders.forEach((value, key) => { - headers.set(key, value); - }); + try { + const mappedHeaders = options.headersMapper(ctx.authInfo, headers); + if (mappedHeaders && typeof mappedHeaders.forEach === 'function') { + mappedHeaders.forEach((value, key) => { + headers.set(key, value); + }); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + throw new Error(`headersMapper failed for tool '${openapiTool.name}': ${errorMessage}`); + } } - // 5. Apply custom body mapper + // 6. Apply custom body mapper with error handling let finalBody = requestBody; if (options.bodyMapper && requestBody) { - finalBody = options.bodyMapper(ctx.authInfo, requestBody); + try { + finalBody = options.bodyMapper(ctx.authInfo, requestBody); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + throw new Error(`bodyMapper failed for tool '${openapiTool.name}': ${errorMessage}`); + } } - // 6. Set content-type if we have a body + // 7. Set content-type if we have a body if (finalBody && !headers.has('content-type')) { headers.set('content-type', 'application/json'); } - // 7. Execute request - const response = await fetch(url, { - method: openapiTool.metadata.method.toUpperCase(), - headers, - body: finalBody ? JSON.stringify(finalBody) : undefined, - }); + // 8. Serialize body and check size limit + let serializedBody: string | undefined; + if (finalBody) { + try { + serializedBody = JSON.stringify(finalBody); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to serialize request body for tool '${openapiTool.name}': ${errorMessage}. ` + + `Body may contain circular references, BigInt, or non-serializable values.`, + ); + } + // Check request body size limit (10MB default) + const maxRequestSize = options.maxRequestSize ?? DEFAULT_MAX_REQUEST_SIZE; + if (serializedBody.length > maxRequestSize) { + throw new Error( + `Request body size (${serializedBody.length} bytes) exceeds maximum allowed (${maxRequestSize} bytes)`, + ); + } + } + + // 9. Execute request with timeout + const requestTimeout = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), requestTimeout); - // 8. Parse and return response - return await parseResponse(response); + try { + const response = await fetch(url, { + method: openapiTool.metadata.method.toUpperCase(), + headers, + body: serializedBody, + signal: controller.signal, + }); + + // 10. Parse and return response + return await parseResponse(response, { maxResponseSize: options.maxResponseSize }); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new Error(`Request timeout after ${requestTimeout}ms for tool '${openapiTool.name}'`); + } + throw err; + } finally { + clearTimeout(timeoutId); + } }); } +/** Default timeout for transform injection: 5 seconds */ +const DEFAULT_TRANSFORM_TIMEOUT_MS = 5000; + +/** Default request timeout: 30 seconds */ +const DEFAULT_REQUEST_TIMEOUT_MS = 30000; + +/** Default max request body size: 10MB */ +const DEFAULT_MAX_REQUEST_SIZE = 10 * 1024 * 1024; + +/** Reserved keys that cannot be used as inputKey (prototype pollution protection) */ +const RESERVED_KEYS = ['__proto__', 'constructor', 'prototype']; + +/** + * Safely inject a single transform value with timeout and error handling + * + * @param transform - Transform to apply + * @param ctx - Transform context + * @param timeoutMs - Timeout in milliseconds + * @returns Injected value or undefined + */ +async function safeInject( + transform: InputTransform, + ctx: InputTransformContext, + timeoutMs: number = DEFAULT_TRANSFORM_TIMEOUT_MS, +): Promise { + let timeoutId: ReturnType | undefined; + + try { + const result = await Promise.race([ + Promise.resolve(transform.inject(ctx)), + new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(`Transform timeout after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + return result; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + throw new Error(`Input transform for '${transform.inputKey}' failed: ${errorMessage}`); + } finally { + // Always clear the timeout to prevent memory leaks + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } +} + +/** + * Validate that input is a plain object (not null, array, or primitive) + */ +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Inject transformed values into the input object + */ +async function injectTransformedValues( + input: unknown, + transforms: InputTransform[], + ctx: InputTransformContext, +): Promise> { + // Validate input is a plain object to prevent prototype pollution + if (!isPlainObject(input)) { + throw new Error(`Invalid input type: expected object, got ${input === null ? 'null' : typeof input}`); + } + + if (transforms.length === 0) return input; + + const result = { ...input }; + + for (const transform of transforms) { + // Prototype pollution protection: reject reserved keys + if (RESERVED_KEYS.includes(transform.inputKey)) { + throw new Error( + `Invalid inputKey '${transform.inputKey}': reserved keys (${RESERVED_KEYS.join(', ')}) cannot be used`, + ); + } + + const value = await safeInject(transform, ctx); + if (value !== undefined) { + result[transform.inputKey] = value; + } + } + + return result; +} + +/** + * Result of schema conversion with success indicator + */ +interface SchemaConversionResult { + schema: z.ZodObject; + conversionFailed: boolean; + error?: string; +} + /** * Converts a JSON Schema to a Zod schema for runtime validation * * @param jsonSchema - JSON Schema * @param toolName - Tool name for error reporting - * @returns Zod schema + * @param logger - Logger instance + * @returns Schema conversion result with success indicator */ -function getZodSchemaFromJsonSchema(jsonSchema: JsonSchema, toolName: string): z.ZodObject { +function getZodSchemaFromJsonSchema( + jsonSchema: JsonSchema, + toolName: string, + logger: FrontMcpLogger, +): SchemaConversionResult { if (typeof jsonSchema !== 'object' || jsonSchema === null) { - return z.object({}).passthrough(); + logger.warn(`[${toolName}] No valid JSON schema provided, using permissive schema`); + return { schema: z.looseObject({}), conversionFailed: true, error: 'No valid JSON schema' }; } try { @@ -85,9 +318,13 @@ function getZodSchemaFromJsonSchema(jsonSchema: JsonSchema, toolName: string): z if (typeof zodSchema?.parse !== 'function') { throw new Error('Conversion did not produce a valid Zod schema.'); } - return zodSchema as unknown as z.ZodObject; + return { schema: zodSchema as unknown as z.ZodObject, conversionFailed: false }; } catch (err: unknown) { - console.error(`Failed to generate Zod schema for '${toolName}':`, err); - return z.object({}).passthrough(); + const errorMessage = err instanceof Error ? err.message : String(err); + logger.warn( + `[${toolName}] Failed to generate Zod schema, using permissive schema. ` + + `Tool will accept any input but may fail at API level. Error: ${errorMessage}`, + ); + return { schema: z.looseObject({}), conversionFailed: true, error: errorMessage }; } } diff --git a/libs/adapters/src/openapi/openapi.types.ts b/libs/adapters/src/openapi/openapi.types.ts index 081c0f16..62d71bea 100644 --- a/libs/adapters/src/openapi/openapi.types.ts +++ b/libs/adapters/src/openapi/openapi.types.ts @@ -1,6 +1,382 @@ import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; -import { OpenAPIV3 } from 'openapi-types'; -import type { LoadOptions, GenerateOptions, McpOpenAPITool, SecurityContext } from 'mcp-from-openapi'; +import { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; +import type { LoadOptions, GenerateOptions, McpOpenAPITool, SecurityContext, ToolMetadata } from 'mcp-from-openapi'; +import type { FrontMcpLogger, ToolAnnotations, ToolExample, ToolUIConfig } from '@frontmcp/sdk'; + +// ============================================================================ +// Input Transform Types +// ============================================================================ + +/** + * Context available when injecting values at request time + */ +export interface InputTransformContext { + /** Authentication info from the MCP session */ + authInfo: AuthInfo; + /** Environment variables */ + env: NodeJS.ProcessEnv; + /** The OpenAPI tool being executed */ + tool: McpOpenAPITool; +} + +/** + * Single input transform configuration. + * Removes an input from the schema and injects its value at request time. + */ +export interface InputTransform { + /** + * The input key (property name in inputSchema) to transform. + * This input will be removed from the schema visible to AI/users. + */ + inputKey: string; + + /** + * Value injector function called at request time. + * Returns the value to inject for this input. + */ + inject: (ctx: InputTransformContext) => unknown | Promise; +} + +/** + * Input transform configuration options. + * Supports global, per-tool, and dynamic transforms. + */ +export interface InputTransformOptions { + /** + * Global transforms applied to ALL tools. + * Use for common patterns like tenant headers. + */ + global?: InputTransform[]; + + /** + * Per-tool transforms keyed by tool name. + * These are combined with global transforms. + */ + perTool?: Record; + + /** + * Dynamic transform generator function. + * Called for each tool to generate transforms programmatically. + * Useful when transforms depend on tool metadata. + */ + generator?: (tool: McpOpenAPITool) => InputTransform[]; +} + +// ============================================================================ +// OpenAPI Extension Types (x-frontmcp) +// ============================================================================ + +/** + * The OpenAPI extension key for FrontMCP metadata. + * Add this to operations in your OpenAPI spec to configure tool behavior. + * + * @example OpenAPI YAML + * ```yaml + * paths: + * /users: + * get: + * operationId: listUsers + * summary: List all users + * x-frontmcp: + * annotations: + * readOnlyHint: true + * idempotentHint: true + * cache: + * ttl: 300 + * tags: + * - users + * ``` + */ +export const FRONTMCP_EXTENSION_KEY = 'x-frontmcp'; + +/** + * Cache configuration for tools. + */ +export interface FrontMcpCacheConfig { + /** + * Time-to-live in seconds for cached responses. + */ + ttl?: number; + + /** + * If true, cache window slides on each access. + * If false, cache expires at fixed time from first access. + */ + slideWindow?: boolean; +} + +/** + * CodeCall configuration for tools. + */ +export interface FrontMcpCodeCallConfig { + /** + * Whether this tool can be used via CodeCall. + * @default true + */ + enabledInCodeCall?: boolean; + + /** + * If true, this tool stays visible in `list_tools` + * even when CodeCall is hiding most tools. + * @default false + */ + visibleInListTools?: boolean; +} + +/** + * Tool annotations that hint at tool behavior. + * These map directly to MCP ToolAnnotations. + */ +export interface FrontMcpAnnotations { + /** + * A human-readable title for the tool. + */ + title?: string; + + /** + * If true, the tool does not modify its environment. + * @default false + */ + readOnlyHint?: boolean; + + /** + * If true, the tool may perform destructive updates. + * If false, the tool performs only additive updates. + * (Meaningful only when readOnlyHint == false) + * @default true + */ + destructiveHint?: boolean; + + /** + * If true, calling repeatedly with same args has no additional effect. + * (Meaningful only when readOnlyHint == false) + * @default false + */ + idempotentHint?: boolean; + + /** + * If true, tool may interact with external entities (open world). + * If false, tool's domain is closed (e.g., memory tool). + * @default true + */ + openWorldHint?: boolean; +} + +/** + * FrontMCP extension schema for OpenAPI operations. + * Add `x-frontmcp` to any operation to configure tool behavior. + * + * @example + * ```yaml + * x-frontmcp: + * annotations: + * readOnlyHint: true + * idempotentHint: true + * cache: + * ttl: 300 + * codecall: + * enabledInCodeCall: true + * visibleInListTools: true + * tags: + * - users + * - public-api + * hideFromDiscovery: false + * ``` + */ +export interface FrontMcpExtension { + /** + * Tool annotations for AI behavior hints. + */ + annotations?: FrontMcpAnnotations; + + /** + * Cache configuration for response caching. + */ + cache?: FrontMcpCacheConfig; + + /** + * CodeCall-specific configuration. + */ + codecall?: FrontMcpCodeCallConfig; + + /** + * Tags/labels for categorization and filtering. + */ + tags?: string[]; + + /** + * If true, hide tool from discovery/listing. + * @default false + */ + hideFromDiscovery?: boolean; + + /** + * Usage examples for the tool. + */ + examples?: Array<{ + description: string; + input: Record; + output?: unknown; + }>; +} + +// ============================================================================ +// Tool Transform Types +// ============================================================================ + +/** + * How to generate tool descriptions from OpenAPI operations. + * - 'summaryOnly': Use only the operation summary (default, current behavior) + * - 'descriptionOnly': Use only the operation description + * - 'combined': Combine summary and description (summary first, then description) + * - 'full': Include summary, description, and operation ID + */ +export type DescriptionMode = 'summaryOnly' | 'descriptionOnly' | 'combined' | 'full'; + +/** + * Transform configuration for modifying generated tools. + * Can override or augment any tool property. + */ +export interface ToolTransform { + /** + * Override or transform the tool name. + * - string: Replace the name entirely + * - function: Transform the existing name + */ + name?: string | ((originalName: string, tool: McpOpenAPITool) => string); + + /** + * Override or transform the tool description. + * - string: Replace the description entirely + * - function: Transform with access to original description and tool metadata + * + * @example + * ```typescript + * // Combine summary and description + * description: (original, tool) => { + * const summary = tool.metadata.operationSummary || ''; + * const desc = tool.metadata.operationDescription || ''; + * return summary && desc ? `${summary}\n\n${desc}` : original; + * } + * ``` + */ + description?: string | ((originalDescription: string, tool: McpOpenAPITool) => string); + + /** + * Annotations to add or merge with existing tool annotations. + * These hint at tool behavior for AI clients. + * + * @example + * ```typescript + * annotations: { + * readOnlyHint: true, // Tool doesn't modify state + * openWorldHint: true, // Tool interacts with external systems + * destructiveHint: false, // Tool doesn't delete data + * idempotentHint: true, // Repeated calls have same effect + * } + * ``` + */ + annotations?: ToolAnnotations; + + /** + * UI configuration for the tool (forms, rendering hints). + */ + ui?: ToolUIConfig; + + /** + * If true, hide the tool from tool discovery/listing. + * The tool can still be called directly. + */ + hideFromDiscovery?: boolean; + + /** + * Tags to add to the tool for categorization. + */ + tags?: string[]; + + /** + * Usage examples to add to the tool. + */ + examples?: ToolExample[]; +} + +/** + * Tool transform configuration options. + * Supports global, per-tool, and dynamic transforms. + */ +export interface ToolTransformOptions { + /** + * Global transforms applied to ALL tools. + * Use for common patterns like adding readOnlyHint to all GET operations. + */ + global?: ToolTransform; + + /** + * Per-tool transforms keyed by tool name. + * Takes precedence over global transforms for overlapping properties. + */ + perTool?: Record; + + /** + * Dynamic transform generator function. + * Called for each tool to generate transforms programmatically. + * Useful when transforms depend on tool metadata (e.g., HTTP method). + * + * @example + * ```typescript + * generator: (tool) => { + * // Mark all GET operations as read-only + * if (tool.metadata.method === 'get') { + * return { + * annotations: { readOnlyHint: true, destructiveHint: false }, + * }; + * } + * // Mark DELETE operations as destructive + * if (tool.metadata.method === 'delete') { + * return { + * annotations: { destructiveHint: true }, + * }; + * } + * return undefined; + * } + * ``` + */ + generator?: (tool: McpOpenAPITool) => ToolTransform | undefined; +} + +// ============================================================================ +// Extended Metadata Types (internal) +// ============================================================================ + +/** + * Extended tool metadata that includes adapter-specific fields. + * This extends the base ToolMetadata from mcp-from-openapi with + * fields used for transforms and extensions. + */ +export interface ExtendedToolMetadata extends ToolMetadata { + /** + * Adapter-specific runtime configuration. + * Contains transforms and other metadata added during tool processing. + */ + adapter?: { + /** Input transforms to apply at request time */ + inputTransforms?: InputTransform[]; + /** Tool transform configuration */ + toolTransform?: ToolTransform; + /** Security schemes that are included in tool input (user provides) */ + securitySchemesInInput?: string[]; + /** Security schemes resolved from context (authProviderMapper, etc.) */ + securitySchemesFromContext?: string[]; + }; +} + +/** + * McpOpenAPITool with extended metadata type. + * Use this type when accessing adapter-extended metadata. + */ +export interface ExtendedMcpOpenAPITool extends Omit { + metadata: ExtendedToolMetadata; +} interface BaseOptions { /** @@ -74,10 +450,7 @@ interface BaseOptions { * } * ``` */ - securityResolver?: ( - tool: McpOpenAPITool, - authInfo: AuthInfo - ) => SecurityContext | Promise; + securityResolver?: (tool: McpOpenAPITool, authInfo: AuthInfo) => SecurityContext | Promise; /** * Map security scheme names to auth provider extractors. @@ -125,13 +498,167 @@ interface BaseOptions { * @see GenerateOptions from mcp-from-openapi */ generateOptions?: GenerateOptions; + + /** + * Specify which security schemes should be included in the tool's input schema. + * Use this for per-scheme control over authentication handling. + * + * - Schemes in this list → included in input (user/AI provides the value) + * - Schemes NOT in this list → resolved from context (authProviderMapper, staticAuth, etc.) + * + * This allows hybrid authentication where some schemes come from user input + * and others come from the session/context. + * + * @example + * ```typescript + * // OpenAPI spec has: GitHubAuth (Bearer), ApiKeyAuth (API key) + * // You want: GitHubAuth from session, ApiKeyAuth from user input + * + * const adapter = new OpenapiAdapter({ + * name: 'my-api', + * baseUrl: 'https://api.example.com', + * spec: mySpec, + * + * // ApiKeyAuth will be in tool input schema + * securitySchemesInInput: ['ApiKeyAuth'], + * + * // GitHubAuth will be resolved from context + * authProviderMapper: { + * 'GitHubAuth': (authInfo) => authInfo.user?.githubToken, + * }, + * }); + * ``` + */ + securitySchemesInInput?: string[]; + + /** + * Input schema transforms for hiding inputs and injecting values at request time. + * + * Use cases: + * - Hide tenant headers from AI/users and inject from session + * - Hide internal correlation IDs and inject from environment + * - Remove API-internal fields that should be computed server-side + * + * @example + * ```typescript + * inputTransforms: { + * global: [ + * { inputKey: 'X-Tenant-Id', inject: (ctx) => ctx.authInfo.user?.tenantId }, + * ], + * perTool: { + * 'createUser': [ + * { inputKey: 'createdBy', inject: (ctx) => ctx.authInfo.user?.email }, + * ], + * }, + * generator: (tool) => { + * if (['post', 'put', 'patch'].includes(tool.metadata.method)) { + * return [{ inputKey: 'X-Correlation-Id', inject: () => crypto.randomUUID() }]; + * } + * return []; + * }, + * } + * ``` + */ + inputTransforms?: InputTransformOptions; + + /** + * Tool transforms for modifying generated tools (description, annotations, UI, etc.). + * + * Use cases: + * - Add annotations (readOnlyHint, openWorldHint) based on HTTP method + * - Override tool descriptions with combined summary + description + * - Add UI configuration for tool forms + * - Hide internal tools from discovery + * + * @example + * ```typescript + * toolTransforms: { + * global: { + * annotations: { openWorldHint: true }, + * }, + * perTool: { + * 'createUser': { + * annotations: { destructiveHint: false }, + * tags: ['user-management'], + * }, + * }, + * generator: (tool) => { + * if (tool.metadata.method === 'get') { + * return { annotations: { readOnlyHint: true } }; + * } + * if (tool.metadata.method === 'delete') { + * return { annotations: { destructiveHint: true } }; + * } + * return undefined; + * }, + * } + * ``` + */ + toolTransforms?: ToolTransformOptions; + + /** + * How to generate tool descriptions from OpenAPI operations. + * - 'summaryOnly': Use only summary (default) + * - 'descriptionOnly': Use only description + * - 'combined': Summary followed by description + * - 'full': Summary, description, and operation details + * + * @default 'summaryOnly' + */ + descriptionMode?: DescriptionMode; + + /** + * Logger instance for adapter diagnostics. + * Optional - if not provided, the SDK will inject it automatically via setLogger(). + */ + logger?: FrontMcpLogger; + + /** + * Timeout for HTTP requests in milliseconds. + * If a request takes longer than this, it will be aborted. + * @default 30000 (30 seconds) + */ + requestTimeoutMs?: number; + + /** + * Maximum request body size in bytes. + * Requests with bodies larger than this will be rejected before sending. + * @default 10485760 (10MB) + */ + maxRequestSize?: number; + + /** + * Maximum response size in bytes. + * Responses larger than this will be rejected. + * The check is performed first on Content-Length header (if present), + * then on actual response size. + * @default 10485760 (10MB) + */ + maxResponseSize?: number; } interface SpecOptions extends BaseOptions { /** - * The OpenAPI specification the OpenAPI specification. + * The OpenAPI specification as a JSON object. + * + * Accepts: + * - `OpenAPIV3.Document` (typed) + * - `OpenAPIV3_1.Document` (typed) + * - Plain object from `import spec from './openapi.json'` + * + * @example + * ```typescript + * // From typed constant + * import { OpenAPIV3 } from 'openapi-types'; + * const spec: OpenAPIV3.Document = { ... }; + * new OpenapiAdapter({ spec, ... }) + * + * // From JSON import + * import openapiJson from './my-openapi.json'; + * new OpenapiAdapter({ spec: openapiJson, ... }) + * ``` */ - spec: OpenAPIV3.Document; + spec: OpenAPIV3.Document | OpenAPIV3_1.Document | object; } interface UrlOptions extends BaseOptions { diff --git a/libs/adapters/src/openapi/openapi.utils.ts b/libs/adapters/src/openapi/openapi.utils.ts index da3ecb62..afc9a21d 100644 --- a/libs/adapters/src/openapi/openapi.utils.ts +++ b/libs/adapters/src/openapi/openapi.utils.ts @@ -9,6 +9,74 @@ export interface RequestConfig { body?: Record; } +/** + * Coerce a value to string with type validation. + * Throws if the value is an object/array that can't be safely stringified. + * + * @param value - Value to coerce + * @param paramName - Parameter name for error messages + * @param location - Parameter location (path/query/header) + * @returns String representation of the value + */ +function coerceToString(value: unknown, paramName: string, location: string): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'object') { + if (Array.isArray(value)) { + // Arrays in query params are common - join with comma + if (location === 'query') { + return value.map(String).join(','); + } + throw new Error(`${location} parameter '${paramName}' cannot be an array. Received: ${JSON.stringify(value)}`); + } + throw new Error(`${location} parameter '${paramName}' cannot be an object. Received: ${JSON.stringify(value)}`); + } + return String(value); +} + +/** + * Validate and normalize a base URL. + * + * @param url - URL to validate + * @returns Validated URL object + * @throws Error if URL is invalid or uses unsupported protocol + */ +export function validateBaseUrl(url: string): URL { + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`Unsupported protocol: ${parsed.protocol}. Only http: and https: are supported.`); + } + return parsed; + } catch (err) { + if (err instanceof Error && err.message.includes('Unsupported protocol')) { + throw err; + } + throw new Error(`Invalid base URL: ${url}`); + } +} + +/** + * Append a cookie to the Cookie header. + * Validates cookie name according to RFC 6265. + * + * @param headers - Headers object to modify + * @param name - Cookie name + * @param value - Cookie value (will be URI encoded) + */ +function appendCookie(headers: Headers, name: string, value: unknown): void { + // RFC 6265 cookie-name validation (simplified) + if (!/^[\w!#$%&'*+\-.^`|~]+$/.test(name)) { + throw new Error(`Invalid cookie name: '${name}'. Cookie names must be valid tokens.`); + } + + const existing = headers.get('Cookie') || ''; + const cookiePair = `${name}=${encodeURIComponent(coerceToString(value, name, 'cookie'))}`; + const combined = existing ? `${existing}; ${cookiePair}` : cookiePair; + headers.set('Cookie', combined); +} + /** * Build HTTP request from OpenAPI tool and input parameters * @@ -24,7 +92,11 @@ export function buildRequest( security: Awaited>, baseUrl: string, ): RequestConfig { - const apiBaseUrl = tool.metadata.servers?.[0]?.url || baseUrl; + // Normalize base URL by removing trailing slash to prevent double slashes + // Validate server URL from OpenAPI spec to prevent SSRF attacks + const rawBaseUrl = tool.metadata.servers?.[0]?.url || baseUrl; + validateBaseUrl(rawBaseUrl); // Throws if invalid protocol (e.g., file://, javascript:) + const apiBaseUrl = rawBaseUrl.replace(/\/+$/, ''); let path = tool.metadata.path; const queryParams = new URLSearchParams(); const headers = new Headers({ @@ -43,7 +115,9 @@ export function buildRequest( // Check required parameters if (value === undefined || value === null) { if (mapper.required) { - throw new Error(`Required parameter '${mapper.inputKey}' is missing for operation ${tool.name}`); + throw new Error( + `Required ${mapper.type} parameter '${mapper.key}' (input: '${mapper.inputKey}') is missing for operation '${tool.name}'`, + ); } continue; } @@ -51,46 +125,63 @@ export function buildRequest( // Apply parameter to correct location switch (mapper.type) { case 'path': - path = path.replace(`{${mapper.key}}`, encodeURIComponent(String(value))); + // Use replaceAll to handle duplicate path parameters (e.g., /users/{id}/posts/{id}) + path = path.replaceAll(`{${mapper.key}}`, encodeURIComponent(coerceToString(value, mapper.key, 'path'))); break; case 'query': - queryParams.set(mapper.key, String(value)); + queryParams.set(mapper.key, coerceToString(value, mapper.key, 'query')); break; - case 'header': - headers.set(mapper.key, String(value)); + case 'header': { + const headerValue = coerceToString(value, mapper.key, 'header'); + // Validate header values for injection attacks + // Check for: CR, LF, null byte, form feed, vertical tab + if (/[\r\n\x00\f\v]/.test(headerValue)) { + throw new Error( + `Invalid header value for '${mapper.key}': contains control characters (possible header injection attack)`, + ); + } + headers.set(mapper.key, headerValue); break; + } + case 'cookie': - // Simple cookie header merge; you may want a more robust cookie encoder. - { - const existing = headers.get('cookie') ?? headers.get('Cookie'); - const cookiePair = `${mapper.key}=${encodeURIComponent(String(value))}`; - const combined = existing ? `${existing}; ${cookiePair}` : cookiePair; - headers.set('Cookie', combined); - } + appendCookie(headers, mapper.key, value); break; case 'body': if (!body) body = {}; body[mapper.key] = value; break; + + default: + throw new Error( + `Unknown mapper type '${(mapper as { type: string }).type}' for parameter '${mapper.key}' in operation '${ + tool.name + }'`, + ); } } // Add query parameters from security (e.g., API keys in query string) + // Detect collisions with user-provided query params (security params take precedence) Object.entries(security.query).forEach(([key, value]) => { - queryParams.set(key, value); + if (queryParams.has(key)) { + // Security params override user params, but warn about potential misconfiguration + throw new Error( + `Query parameter collision: '${key}' is provided both as user input and security parameter. ` + + `This could indicate a security misconfiguration in operation '${tool.name}'.`, + ); + } + queryParams.set(key, coerceToString(value, key, 'security query')); }); // Add cookies from a security context if (security.cookies && Object.keys(security.cookies).length > 0) { - const existing = headers.get('cookie') ?? headers.get('Cookie'); - const securityCookieString = Object.entries(security.cookies) - .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) - .join('; '); - const combined = existing ? `${existing}; ${securityCookieString}` : securityCookieString; - headers.set('Cookie', combined); + Object.entries(security.cookies).forEach(([key, value]) => { + appendCookie(headers, key, value); + }); } // Ensure all path parameters are resolved @@ -119,28 +210,60 @@ export function applyAdditionalHeaders(headers: Headers, additionalHeaders?: Rec }); } +/** + * Options for response parsing + */ +export interface ParseResponseOptions { + /** Maximum response size in bytes (default: 10MB) */ + maxResponseSize?: number; +} + +/** Default max response size: 10MB */ +const DEFAULT_MAX_RESPONSE_SIZE = 10 * 1024 * 1024; + /** * Parse API response based on content type * * @param response - Fetch response + * @param options - Optional parsing options * @returns Parsed response data */ -export async function parseResponse(response: Response): Promise<{ data: unknown }> { - const contentType = response.headers.get('content-type'); - const text = await response.text(); +export async function parseResponse(response: Response, options?: ParseResponseOptions): Promise<{ data: unknown }> { + const maxSize = options?.maxResponseSize ?? DEFAULT_MAX_RESPONSE_SIZE; - // Check for error responses + // Check for error responses FIRST - don't expose response body in error + // Only include status code, not statusText (which could contain sensitive info) if (!response.ok) { - throw new Error(`API request failed: ${response.status} ${response.statusText}\n${text}`); + throw new Error(`API request failed: ${response.status}`); + } + + // Check Content-Length header first to avoid loading huge responses + const contentLength = response.headers.get('content-length'); + if (contentLength) { + const length = parseInt(contentLength, 10); + // Check for NaN, Infinity (from very large numbers), and actual size limit + if (!isNaN(length) && isFinite(length) && length > maxSize) { + throw new Error(`Response size (${length} bytes) exceeds maximum allowed (${maxSize} bytes)`); + } + // If length is Infinity or NaN, we'll catch it in the actual byte size check below } - // Parse JSON responses - if (contentType?.includes('application/json')) { + // Read response body + const text = await response.text(); + + // Also check actual byte size (Content-Length may be missing or incorrect) + const byteSize = new TextEncoder().encode(text).length; + if (byteSize > maxSize) { + throw new Error(`Response size (${byteSize} bytes) exceeds maximum allowed (${maxSize} bytes)`); + } + + // Parse JSON responses - use case-insensitive check + const contentType = response.headers.get('content-type'); + if (contentType?.toLowerCase().includes('application/json')) { try { return { data: JSON.parse(text) }; - } catch (error) { - // Invalid JSON, return as text - console.warn('Failed to parse JSON response:', error); + } catch { + // Invalid JSON, return as text (don't log to console in production) return { data: text }; } } diff --git a/libs/mcp-from-openapi/SECURITY.md b/libs/mcp-from-openapi/SECURITY.md index 86ae6f90..fa9f7681 100644 --- a/libs/mcp-from-openapi/SECURITY.md +++ b/libs/mcp-from-openapi/SECURITY.md @@ -135,15 +135,25 @@ const resolved = resolver.resolve(tool.mapper, { } return undefined; // Fall back to standard resolution - } + }, }); ``` ## Supported Authentication Types +**Auth Type Routing:** Tokens are automatically routed to the correct context field based on the security scheme type: + +| Scheme Type | Context Field | +| -------------- | ------------- | +| `http: bearer` | `jwt` | +| `apiKey` | `apiKey` | +| `http: basic` | `basic` | +| `oauth2` | `oauth2Token` | + ### 1. Bearer Tokens (JWT) **OpenAPI Spec:** + ```yaml securitySchemes: BearerAuth: @@ -153,9 +163,10 @@ securitySchemes: ``` **Usage:** + ```typescript resolver.resolve(tool.mapper, { - jwt: 'eyJhbGciOiJIUzI1NiIs...' + jwt: 'eyJhbGciOiJIUzI1NiIs...', }); // → Authorization: Bearer eyJhbGciOiJIUzI1NiIs... ``` @@ -163,6 +174,7 @@ resolver.resolve(tool.mapper, { ### 2. Basic Authentication **OpenAPI Spec:** + ```yaml securitySchemes: BasicAuth: @@ -171,9 +183,10 @@ securitySchemes: ``` **Usage:** + ```typescript resolver.resolve(tool.mapper, { - basic: btoa('username:password') // base64 encoded + basic: btoa('username:password'), // base64 encoded }); // → Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ= ``` @@ -181,6 +194,7 @@ resolver.resolve(tool.mapper, { ### 3. API Key in Header **OpenAPI Spec:** + ```yaml securitySchemes: ApiKeyAuth: @@ -190,9 +204,10 @@ securitySchemes: ``` **Usage:** + ```typescript resolver.resolve(tool.mapper, { - apiKey: 'sk-1234567890' + apiKey: 'sk-1234567890', }); // → X-API-Key: sk-1234567890 ``` @@ -200,6 +215,7 @@ resolver.resolve(tool.mapper, { ### 4. API Key in Query **OpenAPI Spec:** + ```yaml securitySchemes: ApiKeyAuth: @@ -209,9 +225,10 @@ securitySchemes: ``` **Usage:** + ```typescript const resolved = resolver.resolve(tool.mapper, { - apiKey: 'sk-1234567890' + apiKey: 'sk-1234567890', }); // Add to URL: ?api_key=sk-1234567890 const url = `${baseUrl}?${new URLSearchParams(resolved.query)}`; @@ -220,6 +237,7 @@ const url = `${baseUrl}?${new URLSearchParams(resolved.query)}`; ### 5. OAuth2 / OpenID Connect **OpenAPI Spec:** + ```yaml securitySchemes: OAuth2: @@ -234,9 +252,10 @@ securitySchemes: ``` **Usage:** + ```typescript resolver.resolve(tool.mapper, { - oauth2Token: 'ya29.a0AfH6SMBx...' + oauth2Token: 'ya29.a0AfH6SMBx...', }); // → Authorization: Bearer ya29.a0AfH6SMBx... ``` @@ -283,8 +302,8 @@ const tools = await generator.generateTools({ // Security is in BOTH mapper AND inputSchema // Caller must provide auth explicitly await executeTool({ - BearerAuth: 'my-token', // ← Explicit parameter - userId: 123 + BearerAuth: 'my-token', // ← Explicit parameter + userId: 123, }); ``` @@ -330,7 +349,7 @@ const params = processParameters(tool.mapper, input); // Combine for final request fetch(url, { - headers: { ...security.headers, ...params.headers } + headers: { ...security.headers, ...params.headers }, }); ``` @@ -369,7 +388,7 @@ async function executeOpenAPITool(tool, input, context) { if (missing.length > 0) { throw new Error( `Missing authentication for ${tool.name}: ${missing.join(', ')}\n` + - `Please provide credentials via context or environment variables.` + `Please provide credentials via context or environment variables.`, ); } @@ -410,6 +429,7 @@ async function executeOpenAPITool(tool, input, context) { Resolves security parameters from mappers using the provided context. **Parameters:** + - `mappers: ParameterMapper[]` - Parameter mappers from tool definition - `context: SecurityContext` - Security context with auth values @@ -427,14 +447,16 @@ Checks which security requirements are missing from the context. ```typescript interface SecurityContext { - jwt?: string; - basic?: string; - apiKey?: string; - oauth2Token?: string; + jwt?: string; // Used for http:bearer schemes + basic?: string; // Used for http:basic schemes + apiKey?: string; // Used for apiKey schemes + oauth2Token?: string; // Used for oauth2 schemes customResolver?: (security: SecurityParameterInfo) => string | undefined; } ``` +**Note:** When using `authProviderMapper`, tokens are automatically routed to the correct field based on the security scheme type. See [Auth Type Routing](#supported-authentication-types) for details. + #### `ResolvedSecurity` ```typescript @@ -467,9 +489,10 @@ interface SecurityParameterInfo { **Problem:** SecurityResolver reports missing auth. **Solution:** Check that you're providing the correct auth type: + ```typescript // Check what auth is required -tool.mapper.forEach(m => { +tool.mapper.forEach((m) => { if (m.security) { console.log('Required:', m.security.type, m.security.httpScheme || m.security.scheme); } @@ -481,6 +504,7 @@ tool.mapper.forEach(m => { **Problem:** Requests don't include authentication headers. **Solution:** Make sure you're using the resolved security: + ```typescript const security = resolver.resolve(tool.mapper, context); // Must use security.headers in the request! @@ -492,11 +516,12 @@ fetch(url, { headers: security.headers }); **Problem:** Your API uses a non-standard auth method. **Solution:** Use a custom resolver: + ```typescript const security = resolver.resolve(tool.mapper, { customResolver: (sec) => { return myCustomAuthLogic(sec); - } + }, }); ``` @@ -505,6 +530,7 @@ const security = resolver.resolve(tool.mapper, { ### 6. Digest Authentication **OpenAPI Spec:** + ```yaml securitySchemes: DigestAuth: @@ -513,6 +539,7 @@ securitySchemes: ``` **Usage:** + ```typescript const resolved = await resolver.resolve(tool.mapper, { digest: { @@ -521,8 +548,8 @@ const resolved = await resolver.resolve(tool.mapper, { realm: 'api@example.com', nonce: '...', uri: '/api/resource', - response: '...' // computed digest response - } + response: '...', // computed digest response + }, }); // → Authorization: Digest username="user", realm="api@example.com", nonce="...", ... ``` @@ -530,6 +557,7 @@ const resolved = await resolver.resolve(tool.mapper, { ### 7. Client Certificate Authentication (mTLS) **OpenAPI Spec:** + ```yaml securitySchemes: MutualTLS: @@ -538,14 +566,15 @@ securitySchemes: ``` **Usage:** + ```typescript const resolved = await resolver.resolve(tool.mapper, { clientCertificate: { cert: fs.readFileSync('client-cert.pem', 'utf8'), key: fs.readFileSync('client-key.pem', 'utf8'), passphrase: 'optional-passphrase', - ca: fs.readFileSync('ca-cert.pem', 'utf8') - } + ca: fs.readFileSync('ca-cert.pem', 'utf8'), + }, }); // Use in fetch with certificate @@ -554,7 +583,7 @@ fetch(url, { // In Node.js with https module: cert: resolved.clientCertificate.cert, key: resolved.clientCertificate.key, - ca: resolved.clientCertificate.ca + ca: resolved.clientCertificate.ca, }); ``` @@ -563,6 +592,7 @@ fetch(url, { Some APIs require multiple keys for different purposes: **OpenAPI Spec:** + ```yaml securitySchemes: APIKey: @@ -576,12 +606,13 @@ securitySchemes: ``` **Usage:** + ```typescript const resolved = await resolver.resolve(tool.mapper, { apiKeys: { 'X-API-Key': 'sk-1234567890', - 'X-Client-ID': 'client-abc123' - } + 'X-Client-ID': 'client-abc123', + }, }); // → X-API-Key: sk-1234567890 // → X-Client-ID: client-abc123 @@ -590,6 +621,7 @@ const resolved = await resolver.resolve(tool.mapper, { ### 9. Custom Headers (Proprietary Auth) **OpenAPI Spec:** + ```yaml securitySchemes: CustomAuth: @@ -599,18 +631,20 @@ securitySchemes: ``` **Usage:** + ```typescript const resolved = await resolver.resolve(tool.mapper, { customHeaders: { 'X-Custom-Auth': 'custom-token-format', - 'X-Request-ID': 'uuid-123' - } + 'X-Request-ID': 'uuid-123', + }, }); ``` ### 10. Signature-Based Authentication (HMAC, AWS Signature V4) **OpenAPI Spec:** + ```yaml securitySchemes: AWS4-HMAC-SHA256: @@ -621,6 +655,7 @@ securitySchemes: ``` **Usage:** + ```typescript const resolver = new SecurityResolver(); @@ -630,13 +665,13 @@ const resolved = await resolver.resolve(tool.mapper, { accessKeyId: 'AKIAIOSFODNN7EXAMPLE', secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', region: 'us-east-1', - service: 's3' + service: 's3', }, signatureGenerator: async (data, security) => { // Implement AWS Signature V4 or HMAC signing const signature = awsSign(data, awsCredentials); return `AWS4-HMAC-SHA256 Credential=..., SignedHeaders=..., Signature=${signature}`; - } + }, }); // 2. Check if signing is required @@ -649,11 +684,11 @@ if (resolved.requiresSignature) { url: 'https://s3.amazonaws.com/bucket/key', headers: resolved.headers, body: requestBody, - timestamp: Date.now() + timestamp: Date.now(), }, - securityContext + securityContext, ); - + // 4. Use signed headers fetch(url, { headers: signedHeaders }); } @@ -669,35 +704,33 @@ const resolved = await resolver.resolve(tool.mapper, { signatureGenerator: async (data, security) => { // Create HMAC signature const stringToSign = `${data.method}\n${data.url}\n${data.timestamp}`; - const signature = crypto - .createHmac('sha256', hmacSecret) - .update(stringToSign) - .digest('base64'); - + const signature = crypto.createHmac('sha256', hmacSecret).update(stringToSign).digest('base64'); + return `HMAC-SHA256 ${signature}`; - } + }, }); ``` ### 12. Session Cookies **Usage:** + ```typescript const resolved = await resolver.resolve(tool.mapper, { cookies: { - 'session_id': 'sess_abc123', - 'csrf_token': 'csrf_xyz789' - } + session_id: 'sess_abc123', + csrf_token: 'csrf_xyz789', + }, }); // Cookies are automatically included in resolved.cookies fetch(url, { headers: { ...resolved.headers, - 'Cookie': Object.entries(resolved.cookies) + Cookie: Object.entries(resolved.cookies) .map(([k, v]) => `${k}=${v}`) - .join('; ') - } + .join('; '), + }, }); ``` @@ -712,19 +745,19 @@ const resolved = await resolver.resolve(tool.mapper, { if (security.scheme === 'AWS4-HMAC-SHA256') { return await generateAWSSignature(); } - + if (security.scheme === 'Custom-HMAC') { return await generateHMACSignature(security); } - + if (security.scheme.startsWith('OAuth2-')) { const token = await refreshOAuth2Token(security.scopes); return `Bearer ${token}`; } - + // Fall back to standard resolution return undefined; - } + }, }); ``` @@ -734,24 +767,24 @@ const resolved = await resolver.resolve(tool.mapper, { // FrontMCP with multiple auth types class FrontMCPSecurityContext { constructor(private context: FrontMcpContext) {} - + async resolve(tool: McpOpenAPITool): Promise { const resolver = new SecurityResolver(); - + return resolver.resolve(tool.mapper, { // Standard auth jwt: this.context.authInfo.jwt, apiKey: this.context.authInfo.apiKey, - + // Multiple keys apiKeys: this.context.authInfo.apiKeys || {}, - + // Cookies cookies: this.context.authInfo.cookies || {}, - + // mTLS clientCertificate: this.context.authInfo.clientCertificate, - + // Custom resolver for framework-specific logic customResolver: async (security) => { // Check framework-specific auth providers @@ -761,7 +794,7 @@ class FrontMCPSecurityContext { } return undefined; }, - + // Signature generator signatureGenerator: async (data, security) => { const signer = this.context.signatureProviders.get(security.scheme); @@ -769,7 +802,7 @@ class FrontMCPSecurityContext { return await signer.sign(data, security); } throw new Error(`No signature provider for ${security.scheme}`); - } + }, }); } } @@ -777,19 +810,19 @@ class FrontMCPSecurityContext { ## Security Type Summary -| Type | OpenAPI | Usage | Example | -|------|---------|-------|---------| -| Bearer Token | `http: bearer` | JWT, access tokens | `Authorization: Bearer eyJ...` | -| Basic Auth | `http: basic` | Username:password | `Authorization: Basic dXNlcjpwYXNz` | -| Digest Auth | `http: digest` | Challenge-response | `Authorization: Digest username="..."` | -| API Key (Header) | `apiKey: header` | Simple keys | `X-API-Key: sk-123` | -| API Key (Query) | `apiKey: query` | URL parameters | `?api_key=sk-123` | -| OAuth2 | `oauth2` | OAuth 2.0 flows | `Authorization: Bearer ya29...` | -| OpenID Connect | `openIdConnect` | OIDC tokens | `Authorization: Bearer eyJ...` | -| mTLS | `mutualTLS` | Client certificates | TLS handshake | -| HMAC | Custom `apiKey` | Signed requests | `Authorization: HMAC-SHA256 ...` | -| AWS Signature V4 | Custom `apiKey` | AWS API requests | `Authorization: AWS4-HMAC-SHA256 ...` | -| Custom Headers | `apiKey` | Proprietary | `X-Custom-Auth: value` | -| Cookies | Context | Session management | `Cookie: session_id=...` | +| Type | OpenAPI | Usage | Example | +| ---------------- | ---------------- | ------------------- | -------------------------------------- | +| Bearer Token | `http: bearer` | JWT, access tokens | `Authorization: Bearer eyJ...` | +| Basic Auth | `http: basic` | Username:password | `Authorization: Basic dXNlcjpwYXNz` | +| Digest Auth | `http: digest` | Challenge-response | `Authorization: Digest username="..."` | +| API Key (Header) | `apiKey: header` | Simple keys | `X-API-Key: sk-123` | +| API Key (Query) | `apiKey: query` | URL parameters | `?api_key=sk-123` | +| OAuth2 | `oauth2` | OAuth 2.0 flows | `Authorization: Bearer ya29...` | +| OpenID Connect | `openIdConnect` | OIDC tokens | `Authorization: Bearer eyJ...` | +| mTLS | `mutualTLS` | Client certificates | TLS handshake | +| HMAC | Custom `apiKey` | Signed requests | `Authorization: HMAC-SHA256 ...` | +| AWS Signature V4 | Custom `apiKey` | AWS API requests | `Authorization: AWS4-HMAC-SHA256 ...` | +| Custom Headers | `apiKey` | Proprietary | `X-Custom-Auth: value` | +| Cookies | Context | Session management | `Cookie: session_id=...` | All security types are handled automatically by the SecurityResolver based on the OpenAPI specification metadata! diff --git a/libs/mcp-from-openapi/src/generator.ts b/libs/mcp-from-openapi/src/generator.ts index 42ac1df0..9d5018b5 100644 --- a/libs/mcp-from-openapi/src/generator.ts +++ b/libs/mcp-from-openapi/src/generator.ts @@ -46,10 +46,7 @@ export class OpenAPIToolGenerator { /** * Create generator from a URL */ - static async fromURL( - url: string, - options: LoadOptions = {} - ): Promise { + static async fromURL(url: string, options: LoadOptions = {}): Promise { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeout ?? 30000); @@ -63,10 +60,10 @@ export class OpenAPIToolGenerator { clearTimeout(timeout); if (!response.ok) { - throw new LoadError( - `Failed to fetch OpenAPI spec from URL: ${response.status} ${response.statusText}`, - { url, status: response.status } - ); + throw new LoadError(`Failed to fetch OpenAPI spec from URL: ${response.status} ${response.statusText}`, { + url, + status: response.status, + }); } const contentType = response.headers.get('content-type') || ''; @@ -95,10 +92,7 @@ export class OpenAPIToolGenerator { /** * Create generator from a file path */ - static async fromFile( - filePath: string, - options: LoadOptions = {} - ): Promise { + static async fromFile(filePath: string, options: LoadOptions = {}): Promise { try { const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath); const content = await fs.readFile(absolutePath, 'utf-8'); @@ -131,10 +125,7 @@ export class OpenAPIToolGenerator { /** * Create generator from a YAML string */ - static async fromYAML( - yamlString: string, - options: LoadOptions = {} - ): Promise { + static async fromYAML(yamlString: string, options: LoadOptions = {}): Promise { try { const document = yaml.parse(yamlString); return new OpenAPIToolGenerator(document, options); @@ -149,10 +140,7 @@ export class OpenAPIToolGenerator { /** * Create generator from a JSON object */ - static async fromJSON( - json: object, - options: LoadOptions = {} - ): Promise { + static async fromJSON(json: object, options: LoadOptions = {}): Promise { // Clone to avoid mutations const document = JSON.parse(JSON.stringify(json)); return new OpenAPIToolGenerator(document, options); @@ -187,7 +175,7 @@ export class OpenAPIToolGenerator { if (this.options.dereference && !this.dereferencedDocument) { try { this.dereferencedDocument = (await $RefParser.dereference( - JSON.parse(JSON.stringify(this.document)) + JSON.parse(JSON.stringify(this.document)), )) as OpenAPIDocument; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -240,11 +228,7 @@ export class OpenAPIToolGenerator { /** * Generate a specific tool for a path and method */ - async generateTool( - pathStr: string, - method: string, - options: GenerateOptions = {} - ): Promise { + async generateTool(pathStr: string, method: string, options: GenerateOptions = {}): Promise { await this.initialize(); const document = this.getDocument(); @@ -267,7 +251,7 @@ export class OpenAPIToolGenerator { let pathParameters: ParameterObject[] | undefined = undefined; if (pathItem.parameters) { pathParameters = pathItem.parameters.filter( - (p): p is ParameterObject => !isReferenceObject(p) + (p): p is ParameterObject => !isReferenceObject(p), ) as ParameterObject[]; } @@ -275,17 +259,14 @@ export class OpenAPIToolGenerator { let securityRequirements: SecurityRequirement[] | undefined = undefined; const securitySpec = operation.security ?? document.security; if (securitySpec) { - securityRequirements = this.extractSecurityRequirements( - securitySpec as Record[], - document - ); + securityRequirements = this.extractSecurityRequirements(securitySpec as Record[], document); } const { inputSchema, mapper } = parameterResolver.resolve( operation, pathParameters, securityRequirements, - options.includeSecurityInInput + options.includeSecurityInInput, ); // Build response schema @@ -314,12 +295,7 @@ export class OpenAPIToolGenerator { /** * Check if an operation should be included */ - private shouldIncludeOperation( - operation: any, - path: string, - method: string, - options: GenerateOptions - ): boolean { + private shouldIncludeOperation(operation: any, path: string, method: string, options: GenerateOptions): boolean { // Check deprecated if (operation.deprecated && !options.includeDeprecated) { return false; @@ -343,7 +319,7 @@ export class OpenAPIToolGenerator { return options.filterFn({ ...operation, path, - method + method, } as any); } @@ -357,7 +333,7 @@ export class OpenAPIToolGenerator { path: string, method: HTTPMethod, operationId?: string, - options: GenerateOptions = {} + options: GenerateOptions = {}, ): string { if (options.namingStrategy?.toolNameGenerator) { return options.namingStrategy.toolNameGenerator(path, method, operationId); @@ -385,22 +361,21 @@ export class OpenAPIToolGenerator { method: HTTPMethod, operation: any, document: OpenAPIDocument, - outputSchema?: any + outputSchema?: any, ): any { const metadata: any = { path, method, operationId: operation.operationId, + operationSummary: operation.summary, + operationDescription: operation.description, tags: operation.tags, deprecated: operation.deprecated, }; // Extract security requirements if (operation.security || document.security) { - metadata.security = this.extractSecurityRequirements( - operation.security ?? document.security, - document - ); + metadata.security = this.extractSecurityRequirements(operation.security ?? document.security, document); } // Extract servers @@ -435,6 +410,11 @@ export class OpenAPIToolGenerator { metadata.externalDocs = operation.externalDocs; } + // FrontMCP extension (x-frontmcp) + if (operation['x-frontmcp']) { + metadata.frontmcp = operation['x-frontmcp']; + } + return metadata; } @@ -443,7 +423,7 @@ export class OpenAPIToolGenerator { */ private extractSecurityRequirements( security: Record[], - document: OpenAPIDocument + document: OpenAPIDocument, ): SecurityRequirement[] { if (!security || !document.components?.securitySchemes) { return []; @@ -464,9 +444,8 @@ export class OpenAPIToolGenerator { type: securityScheme.type as AuthType, scopes, name: 'name' in securityScheme ? securityScheme.name : undefined, - in: apiKeyIn && (apiKeyIn === 'query' || apiKeyIn === 'header' || apiKeyIn === 'cookie') - ? apiKeyIn - : undefined, + in: + apiKeyIn && (apiKeyIn === 'query' || apiKeyIn === 'header' || apiKeyIn === 'cookie') ? apiKeyIn : undefined, }; // Add HTTP-specific metadata @@ -479,7 +458,7 @@ export class OpenAPIToolGenerator { result.description = 'description' in securityScheme ? securityScheme.description : undefined; return result; - }) + }), ); } } diff --git a/libs/mcp-from-openapi/src/index.ts b/libs/mcp-from-openapi/src/index.ts index faad1e30..a6708025 100644 --- a/libs/mcp-from-openapi/src/index.ts +++ b/libs/mcp-from-openapi/src/index.ts @@ -15,6 +15,7 @@ export type { McpOpenAPITool, ParameterMapper, ToolMetadata, + FrontMcpExtensionData, SerializationInfo, SecurityRequirement, SecurityParameterInfo, diff --git a/libs/mcp-from-openapi/src/types.ts b/libs/mcp-from-openapi/src/types.ts index 63538d31..c11efbb5 100644 --- a/libs/mcp-from-openapi/src/types.ts +++ b/libs/mcp-from-openapi/src/types.ts @@ -314,6 +314,16 @@ export interface ToolMetadata { */ operationId?: string; + /** + * Operation summary from OpenAPI (short description) + */ + operationSummary?: string; + + /** + * Operation description from OpenAPI (detailed description) + */ + operationDescription?: string; + /** * Tags from OpenAPI */ @@ -343,6 +353,64 @@ export interface ToolMetadata { * External documentation */ externalDocs?: ExternalDocumentationObject; + + /** + * FrontMCP extension data from x-frontmcp in the OpenAPI operation. + * Contains annotations, cache config, codecall config, tags, etc. + */ + frontmcp?: FrontMcpExtensionData; +} + +/** + * FrontMCP extension data extracted from x-frontmcp in OpenAPI operations. + * This provides declarative configuration for tools directly in the OpenAPI spec. + */ +export interface FrontMcpExtensionData { + /** + * Tool annotations for AI behavior hints. + */ + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; + + /** + * Cache configuration for response caching. + */ + cache?: { + ttl?: number; + slideWindow?: boolean; + }; + + /** + * CodeCall-specific configuration. + */ + codecall?: { + enabledInCodeCall?: boolean; + visibleInListTools?: boolean; + }; + + /** + * Tags/labels for categorization. + */ + tags?: string[]; + + /** + * If true, hide tool from discovery. + */ + hideFromDiscovery?: boolean; + + /** + * Usage examples. + */ + examples?: Array<{ + description: string; + input: Record; + output?: unknown; + }>; } /** diff --git a/libs/sdk/src/adapter/adapter.instance.ts b/libs/sdk/src/adapter/adapter.instance.ts index f2186e8b..5ee844be 100644 --- a/libs/sdk/src/adapter/adapter.instance.ts +++ b/libs/sdk/src/adapter/adapter.instance.ts @@ -1,11 +1,4 @@ -import { - AdapterEntry, - AdapterInterface, - AdapterKind, - AdapterRecord, - Ctor, - Reference, -} from '../common'; +import { AdapterEntry, AdapterInterface, AdapterKind, AdapterRecord, Ctor, Reference, FrontMcpLogger } from '../common'; import ProviderRegistry from '../provider/provider.registry'; import ToolRegistry from '../tool/tool.registry'; import ResourceRegistry from '../resource/resource.registry'; @@ -28,7 +21,6 @@ export class AdapterInstance extends AdapterEntry { } protected async initialize() { - const depsTokens = [...this.deps]; const depsInstances = await Promise.all(depsTokens.map((t) => this.globalProviders.resolveBootstrapDep(t))); const rec = this.record; @@ -50,6 +42,12 @@ export class AdapterInstance extends AdapterEntry { throw Error('Invalid adapter kind'); } + // Inject logger if adapter supports it + if (typeof adapter.setLogger === 'function') { + const logger = this.globalProviders.get(FrontMcpLogger); + adapter.setLogger(logger.child(`adapter:${adapter.options.name}`)); + } + const result = await adapter.fetch(); this.tools = new ToolRegistry(this.globalProviders, result.tools ?? [], { @@ -71,6 +69,5 @@ export class AdapterInstance extends AdapterEntry { // }); await this.tools.ready; - } -} \ No newline at end of file +} diff --git a/libs/sdk/src/common/interfaces/adapter.interface.ts b/libs/sdk/src/common/interfaces/adapter.interface.ts index 09a0996a..198b3317 100644 --- a/libs/sdk/src/common/interfaces/adapter.interface.ts +++ b/libs/sdk/src/common/interfaces/adapter.interface.ts @@ -1,11 +1,17 @@ -import {ClassType, FactoryType, Token, Type, ValueType} from './base.interface'; -import {ToolType} from './tool.interface'; -import {ResourceType} from './resource.interface'; -import {PromptType} from './prompt.interface'; -import {AdapterMetadata} from '../metadata'; +import { ClassType, FactoryType, Token, Type, ValueType } from './base.interface'; +import { ToolType } from './tool.interface'; +import { ResourceType } from './resource.interface'; +import { PromptType } from './prompt.interface'; +import { AdapterMetadata } from '../metadata'; +import { FrontMcpLogger } from './logger.interface'; export interface AdapterInterface { options: { name: string } & Record; + /** + * Optional method to receive the SDK logger. + * Called by the SDK before fetch() if implemented. + */ + setLogger?: (logger: FrontMcpLogger) => void; fetch: () => Promise | FrontMcpAdapterResponse; } @@ -15,17 +21,13 @@ export interface FrontMcpAdapterResponse { prompts?: PromptType[]; } - export type AdapterClassType = ClassType & AdapterMetadata; export type AdapterValueType = ValueType & AdapterMetadata; -export type AdapterFactoryType = - FactoryType - & AdapterMetadata; - +export type AdapterFactoryType = FactoryType & + AdapterMetadata; export type AdapterType = | Type | AdapterClassType | AdapterValueType - | AdapterFactoryType - + | AdapterFactoryType; diff --git a/yarn.lock b/yarn.lock index 77a8bc4f..e3a9d4fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38,15 +38,6 @@ ora "5.4.1" rxjs "7.8.1" -"@apidevtools/json-schema-ref-parser@11.7.2": - version "11.7.2" - resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz#cdf3e0aded21492364a70e193b45b7cf4177f031" - integrity sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA== - dependencies: - "@jsdevtools/ono" "^7.1.3" - "@types/json-schema" "^7.0.15" - js-yaml "^4.1.0" - "@apidevtools/json-schema-ref-parser@^11.5.4": version "11.9.3" resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz#0e0c9061fc41cf03737d499a4e6a8299fdd2bfa7" @@ -56,29 +47,6 @@ "@types/json-schema" "^7.0.15" js-yaml "^4.1.0" -"@apidevtools/openapi-schemas@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz#9fa08017fb59d80538812f03fc7cac5992caaa17" - integrity sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ== - -"@apidevtools/swagger-methods@^3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267" - integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg== - -"@apidevtools/swagger-parser@^10.1.1": - version "10.1.1" - resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-10.1.1.tgz#e29bf17cf94b487a340e06784e9fbe20cb671c45" - integrity sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA== - dependencies: - "@apidevtools/json-schema-ref-parser" "11.7.2" - "@apidevtools/openapi-schemas" "^2.1.0" - "@apidevtools/swagger-methods" "^3.0.2" - "@jsdevtools/ono" "^7.1.3" - ajv "^8.17.1" - ajv-draft-04 "^1.0.0" - call-me-maybe "^1.0.2" - "@ark/schema@0.54.0": version "0.54.0" resolved "https://registry.yarnpkg.com/@ark/schema/-/schema-0.54.0.tgz#be1b17c391a9cac53d6eccc0fec41700e95c53a6" @@ -6217,11 +6185,6 @@ call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: call-bind-apply-helpers "^1.0.2" get-intrinsic "^1.3.0" -call-me-maybe@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa" - integrity sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ== - callsites@^3.0.0, callsites@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -12280,15 +12243,6 @@ open@^8.0.4, open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" -openapi-mcp-generator@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/openapi-mcp-generator/-/openapi-mcp-generator-3.2.0.tgz#ac9eeea20af63697399f575b71ee0f8b1aeea727" - integrity sha512-IXa1zfN+Lpq0FSovvBMfI2X8FiJYr6a9zJwHKD4i9fNGaE9g1nV261xBmXletRYU5cHYmOZMEt0P/NosHNyuLw== - dependencies: - "@apidevtools/swagger-parser" "^10.1.1" - commander "^13.1.0" - openapi-types "^12.1.3" - openapi-types@^12.0.0, openapi-types@^12.1.3: version "12.1.3" resolved "https://registry.yarnpkg.com/openapi-types/-/openapi-types-12.1.3.tgz#471995eb26c4b97b7bd356aacf7b91b73e777dd3" From 62d34814cae06c7e30a44997c93ed1f1392e593c Mon Sep 17 00:00:00 2001 From: David Antoon Date: Sat, 13 Dec 2025 13:58:05 +0200 Subject: [PATCH 3/4] feat: Add support for ESM in UI library and update TypeScript configuration --- libs/adapters/tsconfig.lib.json | 8 ++- libs/sdk/tsconfig.lib.json | 6 +- libs/ui/package.json | 121 +++++++------------------------- libs/ui/project.json | 82 ++++++++++++++++++++-- libs/ui/tsconfig.lib.json | 1 + 5 files changed, 116 insertions(+), 102 deletions(-) diff --git a/libs/adapters/tsconfig.lib.json b/libs/adapters/tsconfig.lib.json index 566be4d8..83a3a513 100644 --- a/libs/adapters/tsconfig.lib.json +++ b/libs/adapters/tsconfig.lib.json @@ -3,7 +3,13 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true, - "types": ["node"] + "types": ["node"], + "paths": { + "@frontmcp/ui": ["libs/ui/dist/src/index.d.ts"], + "@frontmcp/ui/*": ["libs/ui/dist/src/*/index.d.ts"], + "@frontmcp/sdk": ["libs/sdk/dist/src/index.d.ts"], + "@frontmcp/sdk/*": ["libs/sdk/dist/src/*/index.d.ts"] + } }, "include": ["src/**/*.ts"], "exclude": [ diff --git a/libs/sdk/tsconfig.lib.json b/libs/sdk/tsconfig.lib.json index 713d08aa..d4e1f093 100644 --- a/libs/sdk/tsconfig.lib.json +++ b/libs/sdk/tsconfig.lib.json @@ -5,7 +5,11 @@ "declaration": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, - "types": ["node"] + "types": ["node"], + "paths": { + "@frontmcp/ui": ["libs/ui/dist/src/index.d.ts"], + "@frontmcp/ui/*": ["libs/ui/dist/src/*/index.d.ts"] + } }, "include": ["src/**/*.ts"], "exclude": [ diff --git a/libs/ui/package.json b/libs/ui/package.json index 5f3c2a57..b149b194 100644 --- a/libs/ui/package.json +++ b/libs/ui/package.json @@ -30,106 +30,35 @@ "bugs": { "url": "https://github.com/agentfront/frontmcp/issues" }, - "main": "./dist/src/index.js", - "types": "./dist/src/index.d.ts", + "type": "commonjs", + "main": "./dist/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/index.d.ts", "exports": { "./package.json": "./package.json", ".": { "development": "./src/index.ts", - "types": "./dist/src/index.d.ts", - "import": "./dist/src/index.js", - "default": "./dist/src/index.js" - }, - "./types": { - "development": "./src/types/index.ts", - "types": "./dist/src/types/index.d.ts", - "import": "./dist/src/types/index.js", - "default": "./dist/src/types/index.js" - }, - "./adapters": { - "development": "./src/adapters/index.ts", - "types": "./dist/src/adapters/index.d.ts", - "import": "./dist/src/adapters/index.js", - "default": "./dist/src/adapters/index.js" - }, - "./build": { - "development": "./src/build/index.ts", - "types": "./dist/src/build/index.d.ts", - "import": "./dist/src/build/index.js", - "default": "./dist/src/build/index.js" - }, - "./renderers": { - "development": "./src/renderers/index.ts", - "types": "./dist/src/renderers/index.d.ts", - "import": "./dist/src/renderers/index.js", - "default": "./dist/src/renderers/index.js" - }, - "./components": { - "development": "./src/components/index.ts", - "types": "./dist/src/components/index.d.ts", - "import": "./dist/src/components/index.js", - "default": "./dist/src/components/index.js" - }, - "./runtime": { - "development": "./src/runtime/index.ts", - "types": "./dist/src/runtime/index.d.ts", - "import": "./dist/src/runtime/index.js", - "default": "./dist/src/runtime/index.js" - }, - "./theme": { - "development": "./src/theme/index.ts", - "types": "./dist/src/theme/index.d.ts", - "import": "./dist/src/theme/index.js", - "default": "./dist/src/theme/index.js" - }, - "./bridge": { - "development": "./src/bridge/index.ts", - "types": "./dist/src/bridge/index.d.ts", - "import": "./dist/src/bridge/index.js", - "default": "./dist/src/bridge/index.js" - }, - "./web-components": { - "development": "./src/web-components/index.ts", - "types": "./dist/src/web-components/index.d.ts", - "import": "./dist/src/web-components/index.js", - "default": "./dist/src/web-components/index.js" - }, - "./react": { - "development": "./src/react/index.ts", - "types": "./dist/src/react/index.d.ts", - "import": "./dist/src/react/index.js", - "default": "./dist/src/react/index.js" - }, - "./render": { - "development": "./src/render/index.ts", - "types": "./dist/src/render/index.d.ts", - "import": "./dist/src/render/index.js", - "default": "./dist/src/render/index.js" - }, - "./styles": { - "development": "./src/styles/index.ts", - "types": "./dist/src/styles/index.d.ts", - "import": "./dist/src/styles/index.js", - "default": "./dist/src/styles/index.js" - }, - "./bundler": { - "development": "./src/bundler/index.ts", - "types": "./dist/src/bundler/index.d.ts", - "import": "./dist/src/bundler/index.js", - "default": "./dist/src/bundler/index.js" - }, - "./handlebars": { - "development": "./src/handlebars/index.ts", - "types": "./dist/src/handlebars/index.d.ts", - "import": "./dist/src/handlebars/index.js", - "default": "./dist/src/handlebars/index.js" - }, - "./registry": { - "development": "./src/registry/index.ts", - "types": "./dist/src/registry/index.d.ts", - "import": "./dist/src/registry/index.js", - "default": "./dist/src/registry/index.js" - } + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./*": { + "development": "./src/*/index.ts", + "require": { + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" + }, + "import": { + "types": "./dist/esm/*/index.d.ts", + "default": "./dist/esm/*/index.js" + } + }, + "./esm": null }, "dependencies": { "@swc/core": "^1.5.0", diff --git a/libs/ui/project.json b/libs/ui/project.json index 329363f1..f7db41a0 100644 --- a/libs/ui/project.json +++ b/libs/ui/project.json @@ -5,19 +5,93 @@ "projectType": "library", "tags": ["scope:libs", "scope:publishable", "versioning:synchronized"], "targets": { - "build-tsc": { - "executor": "@nx/js:tsc", + "build-cjs": { + "executor": "@nx/esbuild:esbuild", "outputs": ["{options.outputPath}"], "options": { "outputPath": "libs/ui/dist", "main": "libs/ui/src/index.ts", "tsConfig": "libs/ui/tsconfig.lib.json", - "assets": ["libs/ui/README.md", "LICENSE"] + "format": ["cjs"], + "declaration": true, + "declarationRootDir": "libs/ui/src", + "bundle": true, + "thirdParty": false, + "platform": "node", + "assets": ["libs/ui/README.md", "LICENSE", "libs/ui/package.json"], + "additionalEntryPoints": [ + "libs/ui/src/adapters/index.ts", + "libs/ui/src/base-template/index.ts", + "libs/ui/src/bridge/index.ts", + "libs/ui/src/build/index.ts", + "libs/ui/src/bundler/index.ts", + "libs/ui/src/components/index.ts", + "libs/ui/src/handlebars/index.ts", + "libs/ui/src/layouts/index.ts", + "libs/ui/src/pages/index.ts", + "libs/ui/src/react/index.ts", + "libs/ui/src/registry/index.ts", + "libs/ui/src/render/index.ts", + "libs/ui/src/renderers/index.ts", + "libs/ui/src/runtime/index.ts", + "libs/ui/src/styles/index.ts", + "libs/ui/src/theme/index.ts", + "libs/ui/src/tool-template/index.ts", + "libs/ui/src/types/index.ts", + "libs/ui/src/validation/index.ts", + "libs/ui/src/web-components/index.ts", + "libs/ui/src/widgets/index.ts" + ], + "esbuildOptions": { + "outExtension": { ".js": ".js" } + } + } + }, + "build-esm": { + "executor": "@nx/esbuild:esbuild", + "dependsOn": ["build-cjs"], + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "libs/ui/dist/esm", + "main": "libs/ui/src/index.ts", + "tsConfig": "libs/ui/tsconfig.lib.json", + "format": ["esm"], + "declaration": true, + "declarationRootDir": "libs/ui/src", + "bundle": true, + "thirdParty": false, + "platform": "node", + "additionalEntryPoints": [ + "libs/ui/src/adapters/index.ts", + "libs/ui/src/base-template/index.ts", + "libs/ui/src/bridge/index.ts", + "libs/ui/src/build/index.ts", + "libs/ui/src/bundler/index.ts", + "libs/ui/src/components/index.ts", + "libs/ui/src/handlebars/index.ts", + "libs/ui/src/layouts/index.ts", + "libs/ui/src/pages/index.ts", + "libs/ui/src/react/index.ts", + "libs/ui/src/registry/index.ts", + "libs/ui/src/render/index.ts", + "libs/ui/src/renderers/index.ts", + "libs/ui/src/runtime/index.ts", + "libs/ui/src/styles/index.ts", + "libs/ui/src/theme/index.ts", + "libs/ui/src/tool-template/index.ts", + "libs/ui/src/types/index.ts", + "libs/ui/src/validation/index.ts", + "libs/ui/src/web-components/index.ts", + "libs/ui/src/widgets/index.ts" + ], + "esbuildOptions": { + "outExtension": { ".js": ".js" } + } } }, "build": { "executor": "nx:run-commands", - "dependsOn": ["build-tsc"], + "dependsOn": ["build-cjs", "build-esm"], "options": { "command": "node scripts/strip-dist-from-pkg.js libs/ui/dist/package.json" } diff --git a/libs/ui/tsconfig.lib.json b/libs/ui/tsconfig.lib.json index 5231e2ac..6324ec71 100644 --- a/libs/ui/tsconfig.lib.json +++ b/libs/ui/tsconfig.lib.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true, + "declarationMap": true, "types": ["node"] }, "include": ["src/**/*.ts", "src/**/*.tsx"], From 90c29caee2206c2c9c7594b7eb45fba7cfefcf7f Mon Sep 17 00:00:00 2001 From: David Antoon Date: Sun, 14 Dec 2025 03:02:52 +0200 Subject: [PATCH 4/4] feat: Update TypeScript configuration for ESM support and remove unused logger export --- libs/adapters/src/openapi/__tests__/fixtures.ts | 2 -- libs/adapters/tsconfig.lib.json | 8 ++++---- libs/sdk/tsconfig.lib.json | 4 ++-- libs/ui/project.json | 1 - 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/libs/adapters/src/openapi/__tests__/fixtures.ts b/libs/adapters/src/openapi/__tests__/fixtures.ts index 7e11fad1..67e5ad71 100644 --- a/libs/adapters/src/openapi/__tests__/fixtures.ts +++ b/libs/adapters/src/openapi/__tests__/fixtures.ts @@ -300,5 +300,3 @@ export const createMockLogger = () => ({ error: jest.fn(), child: jest.fn().mockReturnThis(), }); - -export const mockLogger = createMockLogger(); diff --git a/libs/adapters/tsconfig.lib.json b/libs/adapters/tsconfig.lib.json index 83a3a513..e995023b 100644 --- a/libs/adapters/tsconfig.lib.json +++ b/libs/adapters/tsconfig.lib.json @@ -5,10 +5,10 @@ "declaration": true, "types": ["node"], "paths": { - "@frontmcp/ui": ["libs/ui/dist/src/index.d.ts"], - "@frontmcp/ui/*": ["libs/ui/dist/src/*/index.d.ts"], - "@frontmcp/sdk": ["libs/sdk/dist/src/index.d.ts"], - "@frontmcp/sdk/*": ["libs/sdk/dist/src/*/index.d.ts"] + "@frontmcp/ui": ["libs/ui/dist/index.d.ts"], + "@frontmcp/ui/*": ["libs/ui/dist/*/index.d.ts"], + "@frontmcp/sdk": ["libs/sdk/dist/index.d.ts"], + "@frontmcp/sdk/*": ["libs/sdk/dist/*/index.d.ts"] } }, "include": ["src/**/*.ts"], diff --git a/libs/sdk/tsconfig.lib.json b/libs/sdk/tsconfig.lib.json index d4e1f093..b0bdf66a 100644 --- a/libs/sdk/tsconfig.lib.json +++ b/libs/sdk/tsconfig.lib.json @@ -7,8 +7,8 @@ "experimentalDecorators": true, "types": ["node"], "paths": { - "@frontmcp/ui": ["libs/ui/dist/src/index.d.ts"], - "@frontmcp/ui/*": ["libs/ui/dist/src/*/index.d.ts"] + "@frontmcp/ui": ["libs/ui/dist/index.d.ts"], + "@frontmcp/ui/*": ["libs/ui/dist/*/index.d.ts"] } }, "include": ["src/**/*.ts"], diff --git a/libs/ui/project.json b/libs/ui/project.json index f7db41a0..26ea07aa 100644 --- a/libs/ui/project.json +++ b/libs/ui/project.json @@ -49,7 +49,6 @@ }, "build-esm": { "executor": "@nx/esbuild:esbuild", - "dependsOn": ["build-cjs"], "outputs": ["{options.outputPath}"], "options": { "outputPath": "libs/ui/dist/esm",