From f2a580a0a896e23ee2cd6d754823a73a648e55fb Mon Sep 17 00:00:00 2001 From: iwyrkore Date: Tue, 26 Aug 2025 15:56:40 -0400 Subject: [PATCH] Added passthroughAuth option, --passthrough-auth on the commandline. If passthroughAuth is true, auth headers in MCP call tool requests are passed through to the API requests as specified by the OpenAPI spec. Scheme types http (bearer or basic), apiKey (header, query param, or cookie), and openIdConnect bearer tokens (passed through) as supported. --- CHANGELOG.md | 4 ++++ README.md | 23 +++++++++++---------- package-lock.json | 38 ++++++++++++++++++++++++++--------- package.json | 2 +- src/generator/package-json.ts | 2 +- src/generator/server-code.ts | 8 ++++++-- src/generator/web-server.ts | 6 +++--- src/index.ts | 1 + src/types/index.ts | 2 ++ src/utils/code-gen.ts | 7 ++++--- src/utils/security.ts | 30 ++++++++++++++++++++------- 11 files changed, 86 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a69546..9408801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### Added + +- passthrough-auth option to pass through auth info in MCP request headers to the API, as specified by the openapi spec. + ## [3.2.0] - 2025-08-24 ### Added diff --git a/README.md b/README.md index 7ba4323..b8e32f6 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,18 @@ openapi-mcp-generator --input path/to/openapi.json --output path/to/output/dir - ### CLI Options -| Option | Alias | Description | Default | -| ------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| `--input` | `-i` | Path or URL to OpenAPI specification (YAML or JSON) | **Required** | -| `--output` | `-o` | Directory to output the generated MCP project | **Required** | -| `--server-name` | `-n` | Name of the MCP server (`package.json:name`) | OpenAPI title or `mcp-api-server` | -| `--server-version` | `-v` | Version of the MCP server (`package.json:version`) | OpenAPI version or `1.0.0` | -| `--base-url` | `-b` | Base URL for API requests. Required if OpenAPI `servers` missing or ambiguous. | Auto-detected if possible | -| `--transport` | `-t` | Transport mode: `"stdio"` (default), `"web"`, or `"streamable-http"` | `"stdio"` | -| `--port` | `-p` | Port for web-based transports | `3000` | -| `--default-include` | | Default behavior for x-mcp filtering. Accepts `true` or `false` (case-insensitive). `true` = include by default, `false` = exclude by default. | `true` | -| `--force` | | Overwrite existing files in the output directory without confirmation | `false` | +| Option | Alias | Description | Default | +| -------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `--input` | `-i` | Path or URL to OpenAPI specification (YAML or JSON) | **Required** | +| `--output` | `-o` | Directory to output the generated MCP project | **Required** | +| `--server-name` | `-n` | Name of the MCP server (`package.json:name`) | OpenAPI title or `mcp-api-server` | +| `--server-version` | `-v` | Version of the MCP server (`package.json:version`) | OpenAPI version or `1.0.0` | +| `--base-url` | `-b` | Base URL for API requests. Required if OpenAPI `servers` missing or ambiguous. | Auto-detected if possible | +| `--transport` | `-t` | Transport mode: `"stdio"` (default), `"web"`, or `"streamable-http"` | `"stdio"` | +| `--port` | `-p` | Port for web-based transports | `3000` | +| `--default-include` | | Default behavior for x-mcp filtering. Accepts `true` or `false` (case-insensitive). `true` = include by default, `false` = exclude by default. | `true` | +| `--force` | | Overwrite existing files in the output directory without confirmation | `false` | +| `--passthrough-auth` | | pass through auth info in MCP request headers to the API, as specified by the openapi spec. | `false` | ## 📦 Programmatic API diff --git a/package-lock.json b/package-lock.json index ca49844..079b444 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.10.2", + "@modelcontextprotocol/sdk": "^1.17.4", "json-schema-to-zod": "^2.6.1", "zod": "^3.24.3" } @@ -385,16 +385,17 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.2.tgz", - "integrity": "sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==", - "license": "MIT", + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.4.tgz", + "integrity": "sha512-zq24hfuAmmlNZvik0FLI58uE5sriN0WWsQzIlYnzSuKDAHFqJtBFrl/LfB1NLgJT5Y7dEBzaX4yAKqOPrcetaw==", "peer": true, "dependencies": { + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", @@ -406,6 +407,28 @@ "node": ">=18" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "peer": true + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1540,7 +1563,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -2495,7 +2517,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3091,7 +3112,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index c05d2ce..628530d 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.10.2", + "@modelcontextprotocol/sdk": "^1.17.4", "json-schema-to-zod": "^2.6.1", "zod": "^3.24.3" } diff --git a/src/generator/package-json.ts b/src/generator/package-json.ts index ef97985..b42c491 100644 --- a/src/generator/package-json.ts +++ b/src/generator/package-json.ts @@ -31,7 +31,7 @@ export function generatePackageJson( node: '>=20.0.0', }, dependencies: { - '@modelcontextprotocol/sdk': '^1.10.0', + '@modelcontextprotocol/sdk': '^1.17.4', axios: '^1.9.0', dotenv: '^16.4.5', zod: '^3.24.3', diff --git a/src/generator/server-code.ts b/src/generator/server-code.ts index 6b6a447..c558f28 100644 --- a/src/generator/server-code.ts +++ b/src/generator/server-code.ts @@ -39,7 +39,7 @@ export function generateMcpServerCode( ); // Generate code for request handlers - const callToolHandlerCode = generateCallToolHandler(); + const callToolHandlerCode = generateCallToolHandler(options.passthroughAuth); const listToolsHandlerCode = generateListToolsHandler(); // Determine which transport to include @@ -99,8 +99,12 @@ import { ListToolsRequestSchema, type Tool, type CallToolResult, - type CallToolRequest + type CallToolRequest, + ServerRequest, + ServerNotification, + IsomorphicHeaders } from "@modelcontextprotocol/sdk/types.js";${transportImport} +import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import { z, ZodError } from 'zod'; import { jsonSchemaToZod } from 'json-schema-to-zod'; diff --git a/src/generator/web-server.ts b/src/generator/web-server.ts index 489a563..cab51ce 100644 --- a/src/generator/web-server.ts +++ b/src/generator/web-server.ts @@ -19,7 +19,7 @@ import { serve } from '@hono/node-server'; import { streamSSE } from 'hono/streaming'; import { v4 as uuid } from 'uuid'; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { JSONRPCMessage, JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js"; +import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js"; import type { Context } from 'hono'; import type { SSEStreamingApi } from 'hono/streaming'; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; @@ -37,7 +37,7 @@ private messageUrl: string; onclose?: () => void; onerror?: (error: Error) => void; -onmessage?: (message: JSONRPCMessage) => void; +onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; constructor(messageUrl: string, stream: SSEStreamingApi) { this._sessionId = uuid(); @@ -105,7 +105,7 @@ async handlePostMessage(c: Context): Promise { // Forward to the message handler if (this.onmessage) { - this.onmessage(parsedMessage); + this.onmessage(parsedMessage, {requestInfo: {headers: c.req.header()}}); return c.text('Accepted', 202); } else { return c.text('No message handler defined', 500); diff --git a/src/index.ts b/src/index.ts index d588a24..47e1c43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,6 +86,7 @@ program }, true ) + .option('--passthrough-auth', 'Pass through authentication headers to the API') .option('--force', 'Overwrite existing files without prompting') .version(pkg.version) // Match package.json version .action((options) => { diff --git a/src/types/index.ts b/src/types/index.ts index e36eb3b..8a4826e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -35,6 +35,8 @@ export interface CliOptions { * false = exclude by default unless x-mcp explicitly enables. */ defaultInclude?: boolean; + /** Whether to pass through authentication headers to the API */ + passthroughAuth?: boolean; } /** diff --git a/src/utils/code-gen.ts b/src/utils/code-gen.ts index ae48f1d..0f5ff48 100644 --- a/src/utils/code-gen.ts +++ b/src/utils/code-gen.ts @@ -87,16 +87,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { * * @returns Generated code for the call tool handler */ -export function generateCallToolHandler(): string { +export function generateCallToolHandler(passthroughAuth: boolean | undefined): string { return ` -server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise => { +server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest, extra: RequestHandlerExtra): Promise => { const { name: toolName, arguments: toolArgs } = request.params; const toolDefinition = toolDefinitionMap.get(toolName); if (!toolDefinition) { console.error(\`Error: Unknown tool requested: \${toolName}\`); return { content: [{ type: "text", text: \`Error: Unknown tool requested: \${toolName}\` }] }; } - return await executeApiTool(toolName, toolDefinition, toolArgs ?? {}, securitySchemes); + let sessionHeaders = ${passthroughAuth ? 'extra.requestInfo?.headers' : 'undefined'}; + return await executeApiTool(toolName, toolDefinition, toolArgs ?? {}, securitySchemes, sessionHeaders); }); `; } diff --git a/src/utils/security.ts b/src/utils/security.ts index c6ecbc9..1964158 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -208,6 +208,11 @@ export function generateExecuteApiToolFunction( // Generate security handling code for checking, applying security const securityCode = ` + function getHeaderValue(headers: IsomorphicHeaders | undefined, key: string): string | undefined { + const value = headers?.[key]; + return Array.isArray(value) ? value[0] : value; + } + // Apply security requirements if available // Security requirements use OR between array items and AND within each object const appliedSecurity = definition.securityRequirements?.find(req => { @@ -218,17 +223,18 @@ export function generateExecuteApiToolFunction( // API Key security (header, query, cookie) if (scheme.type === 'apiKey') { - return !!process.env[\`API_KEY_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + return !!(process.env[\`API_KEY_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] || getHeaderValue(sessionHeaders,scheme.name.toLowerCase())); } // HTTP security (basic, bearer) if (scheme.type === 'http') { if (scheme.scheme?.toLowerCase() === 'bearer') { - return !!process.env[\`BEARER_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + return !!(process.env[\`BEARER_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] || getHeaderValue(sessionHeaders,'authorization')?.startsWith('Bearer ')); } else if (scheme.scheme?.toLowerCase() === 'basic') { - return !!process.env[\`BASIC_USERNAME_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] && - !!process.env[\`BASIC_PASSWORD_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + return (!!(process.env[\`BASIC_USERNAME_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] && + !!process.env[\`BASIC_PASSWORD_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]) || + getHeaderValue(sessionHeaders,'authorization')?.startsWith('Basic ')); } } @@ -253,7 +259,7 @@ export function generateExecuteApiToolFunction( // OpenID Connect if (scheme.type === 'openIdConnect') { - return !!process.env[\`OPENID_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + return !!(process.env[\`OPENID_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] || getHeaderValue(sessionHeaders,'authorization')?.startsWith('Bearer ')); } return false; @@ -268,7 +274,7 @@ export function generateExecuteApiToolFunction( // API Key security if (scheme?.type === 'apiKey') { - const apiKey = process.env[\`API_KEY_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + const apiKey = (process.env[\`API_KEY_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] || getHeaderValue(sessionHeaders,scheme.name.toLowerCase())); if (apiKey) { if (scheme.in === 'header') { headers[scheme.name.toLowerCase()] = apiKey; @@ -292,6 +298,9 @@ export function generateExecuteApiToolFunction( if (token) { headers['authorization'] = \`Bearer \${token}\`; console.error(\`Applied Bearer token for '\${schemeName}'\`); + } else if (getHeaderValue(sessionHeaders,'authorization')?.startsWith('Bearer ')) { + headers['authorization'] = getHeaderValue(sessionHeaders,'authorization')!; + console.error(\`Applied Bearer token for '\${schemeName}' from session headers\`); } } else if (scheme.scheme?.toLowerCase() === 'basic') { @@ -300,6 +309,9 @@ export function generateExecuteApiToolFunction( if (username && password) { headers['authorization'] = \`Basic \${Buffer.from(\`\${username}:\${password}\`).toString('base64')}\`; console.error(\`Applied Basic authentication for '\${schemeName}'\`); + } else if (getHeaderValue(sessionHeaders,'authorization')?.startsWith('Basic ')) { + headers['authorization'] = getHeaderValue(sessionHeaders,'authorization')!; + console.error(\`Applied Basic authentication for '\${schemeName}' from session headers\`); } } } @@ -338,6 +350,9 @@ export function generateExecuteApiToolFunction( if (scopes && scopes.length > 0) { console.error(\`Requested scopes: \${scopes.join(', ')}\`); } + } else if (getHeaderValue(sessionHeaders,'authorization')?.startsWith('Bearer ')) { + headers['authorization'] = getHeaderValue(sessionHeaders,'authorization')!; + console.error(\`Applied OpenID Connect token for '\${schemeName}' from session headers\`); } } } @@ -379,7 +394,8 @@ async function executeApiTool( toolName: string, definition: McpToolDefinition, toolArgs: JsonObject, - allSecuritySchemes: Record + allSecuritySchemes: Record, + sessionHeaders: IsomorphicHeaders | undefined ): Promise { try { // Validate arguments against the input schema