├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── docs └── images │ ├── connections.png │ ├── integration-access.png │ ├── integrations-capabilities.png │ ├── integrations-creation.png │ └── page-access-edit.png ├── package-lock.json ├── package.json ├── scripts ├── build-cli.js ├── notion-openapi.json └── start-server.ts ├── smithery.yaml ├── src ├── init-server.ts └── openapi-mcp-server │ ├── README.md │ ├── auth │ ├── index.ts │ ├── template.ts │ └── types.ts │ ├── client │ ├── __tests__ │ │ ├── http-client-upload.test.ts │ │ ├── http-client.integration.test.ts │ │ └── http-client.test.ts │ ├── http-client.ts │ └── polyfill-headers.ts │ ├── index.ts │ ├── mcp │ ├── __tests__ │ │ └── proxy.test.ts │ └── proxy.ts │ └── openapi │ ├── __tests__ │ ├── file-upload.test.ts │ ├── parser-multipart.test.ts │ └── parser.test.ts │ ├── file-upload.ts │ └── parser.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | Dockerfile 3 | docker-compose.yml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | dist 4 | bin/ 5 | 6 | .cache 7 | .yarn/cache 8 | .eslintcache 9 | 10 | .cursor 11 | 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/build/project-config 2 | # syntax=docker/dockerfile:1 3 | 4 | # Use Node.js LTS as the base image 5 | FROM node:20-slim AS builder 6 | 7 | # Set working directory 8 | WORKDIR /app 9 | 10 | # Copy package.json and package-lock.json 11 | COPY package*.json ./ 12 | 13 | # Install dependencies 14 | RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts --omit-dev 15 | 16 | # Copy source code 17 | COPY . . 18 | 19 | # Build the package 20 | RUN --mount=type=cache,target=/root/.npm npm run build 21 | 22 | # Install package globally 23 | RUN --mount=type=cache,target=/root/.npm npm link 24 | 25 | # Minimal image for runtime 26 | FROM node:20-slim 27 | 28 | # Copy built package from builder stage 29 | COPY scripts/notion-openapi.json /usr/local/scripts/ 30 | COPY --from=builder /usr/local/lib/node_modules/@notionhq/notion-mcp-server /usr/local/lib/node_modules/@notionhq/notion-mcp-server 31 | COPY --from=builder /usr/local/bin/notion-mcp-server /usr/local/bin/notion-mcp-server 32 | 33 | # Set default environment variables 34 | ENV OPENAPI_MCP_HEADERS="{}" 35 | 36 | # Set entrypoint 37 | ENTRYPOINT ["notion-mcp-server"] 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Notion Labs, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion MCP Server 2 | 3 | ![notion-mcp-sm](https://github.com/user-attachments/assets/6c07003c-8455-4636-b298-d60ffdf46cd8) 4 | 5 | This project implements an [MCP server](https://spec.modelcontextprotocol.io/) for the [Notion API](https://developers.notion.com/reference/intro). 6 | 7 | ![mcp-demo](https://github.com/user-attachments/assets/e3ff90a7-7801-48a9-b807-f7dd47f0d3d6) 8 | 9 | ### Installation 10 | 11 | #### 1. Setting up Integration in Notion: 12 | Go to [https://www.notion.so/profile/integrations](https://www.notion.so/profile/integrations) and create a new **internal** integration or select an existing one. 13 | 14 | ![Creating a Notion Integration token](docs/images/integrations-creation.png) 15 | 16 | While we limit the scope of Notion API's exposed (for example, you will not be able to delete databases via MCP), there is a non-zero risk to workspace data by exposing it to LLMs. Security-conscious users may want to further configure the Integration's _Capabilities_. 17 | 18 | For example, you can create a read-only integration token by giving only "Read content" access from the "Configuration" tab: 19 | 20 | ![Notion Integration Token Capabilities showing Read content checked](docs/images/integrations-capabilities.png) 21 | 22 | #### 2. Connecting content to integration: 23 | Ensure relevant pages and databases are connected to your integration. 24 | 25 | To do this, visit the **Access** tab in your internal integration settings. Edit access and select the pages you'd like to use. 26 | ![Integration Access tab](docs/images/integration-access.png) 27 | 28 | ![Edit integration access](docs/images/page-access-edit.png) 29 | 30 | Alternatively, you can grant page access individually. You'll need to visit the target page, and click on the 3 dots, and select "Connect to integration". 31 | 32 | ![Adding Integration Token to Notion Connections](docs/images/connections.png) 33 | 34 | #### 3. Adding MCP config to your client: 35 | 36 | ##### Using npm: 37 | Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json` (MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json`) 38 | 39 | ```javascript 40 | { 41 | "mcpServers": { 42 | "notionApi": { 43 | "command": "npx", 44 | "args": ["-y", "@notionhq/notion-mcp-server"], 45 | "env": { 46 | "OPENAPI_MCP_HEADERS": "{\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\" }" 47 | } 48 | } 49 | } 50 | } 51 | ``` 52 | 53 | ##### Using Docker: 54 | 55 | There are two options for running the MCP server with Docker: 56 | 57 | ###### Option 1: Using the official Docker Hub image: 58 | 59 | Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json`: 60 | 61 | ```javascript 62 | { 63 | "mcpServers": { 64 | "notionApi": { 65 | "command": "docker", 66 | "args": [ 67 | "run", 68 | "--rm", 69 | "-i", 70 | "-e", "OPENAPI_MCP_HEADERS", 71 | "mcp/notion" 72 | ], 73 | "env": { 74 | "OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer ntn_****\",\"Notion-Version\":\"2022-06-28\"}" 75 | } 76 | } 77 | } 78 | } 79 | ``` 80 | 81 | This approach: 82 | - Uses the official Docker Hub image 83 | - Properly handles JSON escaping via environment variables 84 | - Provides a more reliable configuration method 85 | 86 | ###### Option 2: Building the Docker image locally: 87 | 88 | You can also build and run the Docker image locally. First, build the Docker image: 89 | 90 | ```bash 91 | docker-compose build 92 | ``` 93 | 94 | Then, add the following to your `.cursor/mcp.json` or `claude_desktop_config.json`: 95 | 96 | ```javascript 97 | { 98 | "mcpServers": { 99 | "notionApi": { 100 | "command": "docker", 101 | "args": [ 102 | "run", 103 | "--rm", 104 | "-i", 105 | "-e", 106 | "OPENAPI_MCP_HEADERS={\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\"}", 107 | "notion-mcp-server" 108 | ] 109 | } 110 | } 111 | } 112 | ``` 113 | 114 | Don't forget to replace `ntn_****` with your integration secret. Find it from your integration configuration tab: 115 | 116 | ![Copying your Integration token from the Configuration tab in the developer portal](https://github.com/user-attachments/assets/67b44536-5333-49fa-809c-59581bf5370a) 117 | 118 | 119 | #### Installing via Smithery 120 | 121 | [![smithery badge](https://smithery.ai/badge/@makernotion/notion-mcp-server)](https://smithery.ai/server/@makernotion/notion-mcp-server) 122 | 123 | To install Notion API Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@makernotion/notion-mcp-server): 124 | 125 | ```bash 126 | npx -y @smithery/cli install @makernotion/notion-mcp-server --client claude 127 | ``` 128 | 129 | ### Examples 130 | 131 | 1. Using the following instruction 132 | ``` 133 | Comment "Hello MCP" on page "Getting started" 134 | ``` 135 | 136 | AI will correctly plan two API calls, `v1/search` and `v1/comments`, to achieve the task 137 | 138 | 2. Similarly, the following instruction will result in a new page named "Notion MCP" added to parent page "Development" 139 | ``` 140 | Add a page titled "Notion MCP" to page "Development" 141 | ``` 142 | 143 | 3. You may also reference content ID directly 144 | ``` 145 | Get the content of page 1a6b35e6e67f802fa7e1d27686f017f2 146 | ``` 147 | 148 | ### Development 149 | 150 | Build 151 | 152 | ``` 153 | npm run build 154 | ``` 155 | 156 | Execute 157 | 158 | ``` 159 | npx -y --prefix /path/to/local/notion-mcp-server @notionhq/notion-mcp-server 160 | ``` 161 | 162 | Publish 163 | 164 | ``` 165 | npm publish --access public 166 | ``` 167 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | notion-mcp-server: 3 | build: . 4 | stdin_open: true 5 | tty: true 6 | restart: unless-stopped 7 | -------------------------------------------------------------------------------- /docs/images/connections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/makenotion/notion-mcp-server/ec7202113e6bf4fede21b0cfb115b473665551b2/docs/images/connections.png -------------------------------------------------------------------------------- /docs/images/integration-access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/makenotion/notion-mcp-server/ec7202113e6bf4fede21b0cfb115b473665551b2/docs/images/integration-access.png -------------------------------------------------------------------------------- /docs/images/integrations-capabilities.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/makenotion/notion-mcp-server/ec7202113e6bf4fede21b0cfb115b473665551b2/docs/images/integrations-capabilities.png -------------------------------------------------------------------------------- /docs/images/integrations-creation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/makenotion/notion-mcp-server/ec7202113e6bf4fede21b0cfb115b473665551b2/docs/images/integrations-creation.png -------------------------------------------------------------------------------- /docs/images/page-access-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/makenotion/notion-mcp-server/ec7202113e6bf4fede21b0cfb115b473665551b2/docs/images/page-access-edit.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@notionhq/notion-mcp-server", 3 | "keywords": [ 4 | "notion", 5 | "api", 6 | "mcp", 7 | "server" 8 | ], 9 | "version": "1.8.1", 10 | "license": "MIT", 11 | "type": "module", 12 | "scripts": { 13 | "build": "tsc -build && node scripts/build-cli.js", 14 | "dev": "tsx watch scripts/start-server.ts" 15 | }, 16 | "bin": { 17 | "notion-mcp-server": "bin/cli.mjs" 18 | }, 19 | "dependencies": { 20 | "@modelcontextprotocol/sdk": "^1.8.0", 21 | "axios": "^1.8.4", 22 | "express": "^4.21.2", 23 | "form-data": "^4.0.1", 24 | "mustache": "^4.2.0", 25 | "openapi-client-axios": "^7.5.5", 26 | "openapi-schema-validator": "^12.1.3", 27 | "openapi-types": "^12.1.3", 28 | "which": "^5.0.0", 29 | "zod": "3.24.1" 30 | }, 31 | "devDependencies": { 32 | "@anthropic-ai/sdk": "^0.33.1", 33 | "@types/express": "^5.0.0", 34 | "@types/js-yaml": "^4.0.9", 35 | "@types/json-schema": "^7.0.15", 36 | "@types/mustache": "^4.2.5", 37 | "@types/node": "^20.17.16", 38 | "@types/which": "^3.0.4", 39 | "@vitest/coverage-v8": "3.1.1", 40 | "esbuild": "^0.25.2", 41 | "multer": "1.4.5-lts.1", 42 | "openai": "^4.91.1", 43 | "tsx": "^4.19.3", 44 | "typescript": "^5.8.2", 45 | "vitest": "^3.1.1" 46 | }, 47 | "description": "Official MCP server for Notion API", 48 | "main": "index.js", 49 | "repository": { 50 | "type": "git", 51 | "url": "git@github.com:makenotion/notion-mcp-server.git" 52 | }, 53 | "author": "@notionhq", 54 | "bugs": { 55 | "url": "https://github.com/makenotion/notion-mcp-server/issues" 56 | }, 57 | "homepage": "https://github.com/makenotion/notion-mcp-server#readme" 58 | } 59 | -------------------------------------------------------------------------------- /scripts/build-cli.js: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild'; 2 | import { chmod } from 'fs/promises'; 3 | import { fileURLToPath } from 'url'; 4 | import { dirname, join } from 'path'; 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)); 7 | 8 | async function build() { 9 | await esbuild.build({ 10 | entryPoints: [join(__dirname, 'start-server.ts')], 11 | bundle: true, 12 | minify: true, 13 | platform: 'node', 14 | target: 'node18', 15 | format: 'esm', 16 | outfile: 'bin/cli.mjs', 17 | banner: { 18 | js: "#!/usr/bin/env node\nimport { createRequire } from 'module';const require = createRequire(import.meta.url);" // see https://github.com/evanw/esbuild/pull/2067 19 | }, 20 | external: ['util'], 21 | }); 22 | 23 | // Make the output file executable 24 | await chmod('./bin/cli.mjs', 0o755); 25 | } 26 | 27 | build().catch((err) => { 28 | console.error(err); 29 | process.exit(1); 30 | }); 31 | -------------------------------------------------------------------------------- /scripts/start-server.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath } from 'url' 3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' 4 | 5 | import { initProxy, ValidationError } from '../src/init-server' 6 | 7 | export async function startServer(args: string[] = process.argv.slice(2)) { 8 | const filename = fileURLToPath(import.meta.url) 9 | const directory = path.dirname(filename) 10 | const specPath = path.resolve(directory, '../scripts/notion-openapi.json') 11 | 12 | const baseUrl = process.env.BASE_URL ?? undefined 13 | 14 | const proxy = await initProxy(specPath, baseUrl) 15 | await proxy.connect(new StdioServerTransport()) 16 | 17 | return proxy.getServer() 18 | } 19 | 20 | startServer().catch(error => { 21 | if (error instanceof ValidationError) { 22 | console.error('Invalid OpenAPI 3.1 specification:') 23 | error.errors.forEach(err => console.error(err)) 24 | } else { 25 | console.error('Error:', error) 26 | } 27 | process.exit(1) 28 | }) 29 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/build/project-config 2 | 3 | startCommand: 4 | type: stdio 5 | commandFunction: 6 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 7 | |- 8 | (config) => { 9 | const env = { OPENAPI_MCP_HEADERS: config.openapiMcpHeaders }; 10 | if (config.baseUrl) env.BASE_URL = config.baseUrl; 11 | return { command: 'notion-mcp-server', args: [], env }; 12 | } 13 | configSchema: 14 | # JSON Schema defining the configuration options for the MCP. 15 | type: object 16 | required: 17 | - openapiMcpHeaders 18 | properties: 19 | openapiMcpHeaders: 20 | type: string 21 | default: "{}" 22 | description: JSON string for HTTP headers, must include Authorization and 23 | Notion-Version 24 | baseUrl: 25 | type: string 26 | description: Optional override for Notion API base URL 27 | exampleConfig: 28 | openapiMcpHeaders: '{"Authorization":"Bearer ntn_abcdef","Notion-Version":"2022-06-28"}' 29 | baseUrl: https://api.notion.com 30 | -------------------------------------------------------------------------------- /src/init-server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | 4 | import { OpenAPIV3 } from 'openapi-types' 5 | import OpenAPISchemaValidator from 'openapi-schema-validator' 6 | 7 | import { MCPProxy } from './openapi-mcp-server/mcp/proxy' 8 | 9 | export class ValidationError extends Error { 10 | constructor(public errors: any[]) { 11 | super('OpenAPI validation failed') 12 | this.name = 'ValidationError' 13 | } 14 | } 15 | 16 | async function loadOpenApiSpec(specPath: string, baseUrl: string | undefined): Promise { 17 | let rawSpec: string 18 | 19 | try { 20 | rawSpec = fs.readFileSync(path.resolve(process.cwd(), specPath), 'utf-8') 21 | } catch (error) { 22 | console.error('Failed to read OpenAPI specification file:', (error as Error).message) 23 | process.exit(1) 24 | } 25 | 26 | // Parse and validate the OpenApi Spec 27 | try { 28 | const parsed = JSON.parse(rawSpec) 29 | 30 | // Override baseUrl if specified. 31 | if (baseUrl) { 32 | parsed.servers[0].url = baseUrl 33 | } 34 | 35 | return parsed as OpenAPIV3.Document 36 | } catch (error) { 37 | if (error instanceof ValidationError) { 38 | throw error 39 | } 40 | console.error('Failed to parse OpenAPI spec:', (error as Error).message) 41 | process.exit(1) 42 | } 43 | } 44 | 45 | export async function initProxy(specPath: string, baseUrl: string |undefined) { 46 | const openApiSpec = await loadOpenApiSpec(specPath, baseUrl) 47 | const proxy = new MCPProxy('Notion API', openApiSpec) 48 | 49 | return proxy 50 | } 51 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/README.md: -------------------------------------------------------------------------------- 1 | Note: This is a fork from v1 of https://github.com/snaggle-ai/openapi-mcp-server. The library took a different direction with v2 which is not compatible with our development approach. 2 | 3 | Forked to upgrade vulnerable dependencies and easier setup. 4 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | export * from './template' 3 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/auth/template.ts: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache' 2 | import { AuthTemplate, TemplateContext } from './types' 3 | 4 | export function renderAuthTemplate(template: AuthTemplate, context: TemplateContext): AuthTemplate { 5 | // Disable HTML escaping for URLs 6 | Mustache.escape = (text) => text 7 | 8 | // Render URL with template variables 9 | const renderedUrl = Mustache.render(template.url, context) 10 | 11 | // Create a new template object with rendered values 12 | const renderedTemplate: AuthTemplate = { 13 | ...template, 14 | url: renderedUrl, 15 | headers: { ...template.headers }, // Create a new headers object to avoid modifying the original 16 | } 17 | 18 | // Render body if it exists 19 | if (template.body) { 20 | renderedTemplate.body = Mustache.render(template.body, context) 21 | } 22 | 23 | return renderedTemplate 24 | } 25 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/auth/types.ts: -------------------------------------------------------------------------------- 1 | export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' 2 | 3 | export interface AuthTemplate { 4 | url: string 5 | method: HttpMethod 6 | headers: Record 7 | body?: string 8 | } 9 | 10 | export interface SecurityScheme { 11 | [key: string]: { 12 | tokenUrl?: string 13 | [key: string]: any 14 | } 15 | } 16 | 17 | export interface Server { 18 | url: string 19 | description?: string 20 | } 21 | 22 | export interface TemplateContext { 23 | securityScheme?: SecurityScheme 24 | servers?: Server[] 25 | args: Record 26 | } 27 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/client/__tests__/http-client-upload.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest' 2 | import { HttpClient } from '../http-client' 3 | import { OpenAPIV3 } from 'openapi-types' 4 | import fs from 'fs' 5 | import FormData from 'form-data' 6 | 7 | vi.mock('fs') 8 | vi.mock('form-data') 9 | 10 | describe('HttpClient File Upload', () => { 11 | let client: HttpClient 12 | const mockApiInstance = { 13 | uploadFile: vi.fn(), 14 | } 15 | 16 | const baseConfig = { 17 | baseUrl: 'http://test.com', 18 | headers: {}, 19 | } 20 | 21 | const mockOpenApiSpec: OpenAPIV3.Document = { 22 | openapi: '3.0.0', 23 | info: { 24 | title: 'Test API', 25 | version: '1.0.0', 26 | }, 27 | paths: { 28 | '/upload': { 29 | post: { 30 | operationId: 'uploadFile', 31 | responses: { 32 | '200': { 33 | description: 'File uploaded successfully', 34 | content: { 35 | 'application/json': { 36 | schema: { 37 | type: 'object', 38 | properties: { 39 | success: { 40 | type: 'boolean', 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | requestBody: { 49 | content: { 50 | 'multipart/form-data': { 51 | schema: { 52 | type: 'object', 53 | properties: { 54 | file: { 55 | type: 'string', 56 | format: 'binary', 57 | }, 58 | description: { 59 | type: 'string', 60 | }, 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | }, 69 | } 70 | 71 | beforeEach(() => { 72 | vi.clearAllMocks() 73 | client = new HttpClient(baseConfig, mockOpenApiSpec) 74 | // @ts-expect-error - Mock the private api property 75 | client['api'] = Promise.resolve(mockApiInstance) 76 | }) 77 | 78 | it('should handle file uploads with FormData', async () => { 79 | const mockFormData = new FormData() 80 | const mockFileStream = { pipe: vi.fn() } 81 | const mockFormDataHeaders = { 'content-type': 'multipart/form-data; boundary=---123' } 82 | 83 | vi.mocked(fs.createReadStream).mockReturnValue(mockFileStream as any) 84 | vi.mocked(FormData.prototype.append).mockImplementation(() => {}) 85 | vi.mocked(FormData.prototype.getHeaders).mockReturnValue(mockFormDataHeaders) 86 | 87 | const uploadPath = mockOpenApiSpec.paths['/upload'] 88 | if (!uploadPath?.post) { 89 | throw new Error('Upload path not found in spec') 90 | } 91 | const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string } 92 | const params = { 93 | file: '/path/to/test.txt', 94 | description: 'Test file', 95 | } 96 | 97 | mockApiInstance.uploadFile.mockResolvedValue({ 98 | data: { success: true }, 99 | status: 200, 100 | headers: {}, 101 | }) 102 | 103 | await client.executeOperation(operation, params) 104 | 105 | expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test.txt') 106 | expect(FormData.prototype.append).toHaveBeenCalledWith('file', mockFileStream) 107 | expect(FormData.prototype.append).toHaveBeenCalledWith('description', 'Test file') 108 | expect(mockApiInstance.uploadFile).toHaveBeenCalledWith({}, expect.any(FormData), { headers: mockFormDataHeaders }) 109 | }) 110 | 111 | it('should throw error for invalid file path', async () => { 112 | vi.mocked(fs.createReadStream).mockImplementation(() => { 113 | throw new Error('File not found') 114 | }) 115 | 116 | const uploadPath = mockOpenApiSpec.paths['/upload'] 117 | if (!uploadPath?.post) { 118 | throw new Error('Upload path not found in spec') 119 | } 120 | const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string } 121 | const params = { 122 | file: '/nonexistent/file.txt', 123 | description: 'Test file', 124 | } 125 | 126 | await expect(client.executeOperation(operation, params)).rejects.toThrow('Failed to read file at /nonexistent/file.txt') 127 | }) 128 | 129 | it('should handle multiple file uploads', async () => { 130 | const mockFormData = new FormData() 131 | const mockFileStream1 = { pipe: vi.fn() } 132 | const mockFileStream2 = { pipe: vi.fn() } 133 | const mockFormDataHeaders = { 'content-type': 'multipart/form-data; boundary=---123' } 134 | 135 | vi.mocked(fs.createReadStream) 136 | .mockReturnValueOnce(mockFileStream1 as any) 137 | .mockReturnValueOnce(mockFileStream2 as any) 138 | vi.mocked(FormData.prototype.append).mockImplementation(() => {}) 139 | vi.mocked(FormData.prototype.getHeaders).mockReturnValue(mockFormDataHeaders) 140 | 141 | const operation: OpenAPIV3.OperationObject = { 142 | operationId: 'uploadFile', 143 | responses: { 144 | '200': { 145 | description: 'Files uploaded successfully', 146 | content: { 147 | 'application/json': { 148 | schema: { 149 | type: 'object', 150 | properties: { 151 | success: { 152 | type: 'boolean', 153 | }, 154 | }, 155 | }, 156 | }, 157 | }, 158 | }, 159 | }, 160 | requestBody: { 161 | content: { 162 | 'multipart/form-data': { 163 | schema: { 164 | type: 'object', 165 | properties: { 166 | file1: { 167 | type: 'string', 168 | format: 'binary', 169 | }, 170 | file2: { 171 | type: 'string', 172 | format: 'binary', 173 | }, 174 | description: { 175 | type: 'string', 176 | }, 177 | }, 178 | }, 179 | }, 180 | }, 181 | }, 182 | } 183 | 184 | const params = { 185 | file1: '/path/to/test1.txt', 186 | file2: '/path/to/test2.txt', 187 | description: 'Test files', 188 | } 189 | 190 | mockApiInstance.uploadFile.mockResolvedValue({ 191 | data: { success: true }, 192 | status: 200, 193 | headers: {}, 194 | }) 195 | 196 | await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, params) 197 | 198 | expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test1.txt') 199 | expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test2.txt') 200 | expect(FormData.prototype.append).toHaveBeenCalledWith('file1', mockFileStream1) 201 | expect(FormData.prototype.append).toHaveBeenCalledWith('file2', mockFileStream2) 202 | expect(FormData.prototype.append).toHaveBeenCalledWith('description', 'Test files') 203 | expect(mockApiInstance.uploadFile).toHaveBeenCalledWith({}, expect.any(FormData), { headers: mockFormDataHeaders }) 204 | }) 205 | }) 206 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from 'vitest' 2 | import { HttpClient } from '../http-client' 3 | import type express from 'express' 4 | //@ts-ignore 5 | import { createPetstoreServer } from '../../../examples/petstore-server.cjs' 6 | import type { OpenAPIV3 } from 'openapi-types' 7 | import axios from 'axios' 8 | 9 | interface Pet { 10 | id: number 11 | name: string 12 | species: string 13 | age: number 14 | status: 'available' | 'pending' | 'sold' 15 | } 16 | 17 | describe('HttpClient Integration Tests', () => { 18 | const PORT = 3456 19 | const BASE_URL = `http://localhost:${PORT}` 20 | let server: ReturnType 21 | let openApiSpec: OpenAPIV3.Document 22 | let client: HttpClient 23 | 24 | beforeAll(async () => { 25 | // Start the petstore server 26 | server = createPetstoreServer(PORT) as unknown as express.Express 27 | 28 | // Fetch the OpenAPI spec from the server 29 | const response = await axios.get(`${BASE_URL}/openapi.json`) 30 | openApiSpec = response.data 31 | 32 | // Create HTTP client 33 | client = new HttpClient( 34 | { 35 | baseUrl: BASE_URL, 36 | headers: { 37 | Accept: 'application/json', 38 | }, 39 | }, 40 | openApiSpec, 41 | ) 42 | }) 43 | 44 | afterAll(() => { 45 | //@ts-expect-error 46 | server.close() 47 | }) 48 | 49 | it('should list all pets', async () => { 50 | const operation = openApiSpec.paths['/pets']?.get 51 | if (!operation) throw new Error('Operation not found') 52 | 53 | const response = await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }) 54 | 55 | expect(response.status).toBe(200) 56 | expect(Array.isArray(response.data)).toBe(true) 57 | expect(response.data.length).toBeGreaterThan(0) 58 | expect(response.data[0]).toHaveProperty('name') 59 | expect(response.data[0]).toHaveProperty('species') 60 | expect(response.data[0]).toHaveProperty('status') 61 | }) 62 | 63 | it('should filter pets by status', async () => { 64 | const operation = openApiSpec.paths['/pets']?.get as OpenAPIV3.OperationObject & { method: string; path: string } 65 | if (!operation) throw new Error('Operation not found') 66 | 67 | const response = await client.executeOperation(operation, { status: 'available' }) 68 | 69 | expect(response.status).toBe(200) 70 | expect(Array.isArray(response.data)).toBe(true) 71 | response.data.forEach((pet: Pet) => { 72 | expect(pet.status).toBe('available') 73 | }) 74 | }) 75 | 76 | it('should get a specific pet by ID', async () => { 77 | const operation = openApiSpec.paths['/pets/{id}']?.get as OpenAPIV3.OperationObject & { method: string; path: string } 78 | if (!operation) throw new Error('Operation not found') 79 | 80 | const response = await client.executeOperation(operation, { id: 1 }) 81 | 82 | expect(response.status).toBe(200) 83 | expect(response.data).toHaveProperty('id', 1) 84 | expect(response.data).toHaveProperty('name') 85 | expect(response.data).toHaveProperty('species') 86 | }) 87 | 88 | it('should create a new pet', async () => { 89 | const operation = openApiSpec.paths['/pets']?.post as OpenAPIV3.OperationObject & { method: string; path: string } 90 | if (!operation) throw new Error('Operation not found') 91 | 92 | const newPet = { 93 | name: 'TestPet', 94 | species: 'Dog', 95 | age: 2, 96 | } 97 | 98 | const response = await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, newPet) 99 | 100 | expect(response.status).toBe(201) 101 | expect(response.data).toMatchObject({ 102 | ...newPet, 103 | status: 'available', 104 | }) 105 | expect(response.data.id).toBeDefined() 106 | }) 107 | 108 | it("should update a pet's status", async () => { 109 | const operation = openApiSpec.paths['/pets/{id}']?.put 110 | if (!operation) throw new Error('Operation not found') 111 | 112 | const response = await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, { 113 | id: 1, 114 | status: 'sold', 115 | }) 116 | 117 | expect(response.status).toBe(200) 118 | expect(response.data).toHaveProperty('id', 1) 119 | expect(response.data).toHaveProperty('status', 'sold') 120 | }) 121 | 122 | it('should delete a pet', async () => { 123 | // First create a pet to delete 124 | const createOperation = openApiSpec.paths['/pets']?.post 125 | if (!createOperation) throw new Error('Operation not found') 126 | 127 | const createResponse = await client.executeOperation( 128 | createOperation as OpenAPIV3.OperationObject & { method: string; path: string }, 129 | { 130 | name: 'ToDelete', 131 | species: 'Cat', 132 | age: 3, 133 | }, 134 | ) 135 | const petId = createResponse.data.id 136 | 137 | // Then delete it 138 | const deleteOperation = openApiSpec.paths['/pets/{id}']?.delete 139 | if (!deleteOperation) throw new Error('Operation not found') 140 | 141 | const deleteResponse = await client.executeOperation(deleteOperation as OpenAPIV3.OperationObject & { method: string; path: string }, { 142 | id: petId, 143 | }) 144 | 145 | expect(deleteResponse.status).toBe(204) 146 | 147 | // Verify the pet is deleted 148 | const getOperation = openApiSpec.paths['/pets/{id}']?.get 149 | if (!getOperation) throw new Error('Operation not found') 150 | 151 | try { 152 | await client.executeOperation(getOperation as OpenAPIV3.OperationObject & { method: string; path: string }, { id: petId }) 153 | throw new Error('Should not reach here') 154 | } catch (error: any) { 155 | expect(error.message).toContain('404') 156 | } 157 | }) 158 | 159 | it('should handle errors appropriately', async () => { 160 | const operation = openApiSpec.paths['/pets/{id}']?.get as OpenAPIV3.OperationObject & { method: string; path: string } 161 | if (!operation) throw new Error('Operation not found') 162 | 163 | try { 164 | await client.executeOperation( 165 | operation as OpenAPIV3.OperationObject & { method: string; path: string }, 166 | { id: 99999 }, // Non-existent ID 167 | ) 168 | throw new Error('Should not reach here') 169 | } catch (error: any) { 170 | expect(error.message).toContain('404') 171 | } 172 | }) 173 | }) 174 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/client/__tests__/http-client.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpClientError } from '../http-client' 2 | import { OpenAPIV3 } from 'openapi-types' 3 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' 4 | import OpenAPIClientAxios from 'openapi-client-axios' 5 | 6 | // Mock the OpenAPIClientAxios initialization 7 | vi.mock('openapi-client-axios', () => { 8 | const mockApi = { 9 | getPet: vi.fn(), 10 | testOperation: vi.fn(), 11 | complexOperation: vi.fn(), 12 | } 13 | return { 14 | default: vi.fn().mockImplementation(() => ({ 15 | init: vi.fn().mockResolvedValue(mockApi), 16 | })), 17 | } 18 | }) 19 | 20 | describe('HttpClient', () => { 21 | let client: HttpClient 22 | let mockApi: any 23 | 24 | const sampleSpec: OpenAPIV3.Document = { 25 | openapi: '3.0.0', 26 | info: { title: 'Test API', version: '1.0.0' }, 27 | paths: { 28 | '/pets/{petId}': { 29 | get: { 30 | operationId: 'getPet', 31 | parameters: [ 32 | { 33 | name: 'petId', 34 | in: 'path', 35 | required: true, 36 | schema: { type: 'integer' }, 37 | }, 38 | ], 39 | responses: { 40 | '200': { 41 | description: 'OK', 42 | content: { 43 | 'application/json': { 44 | schema: { type: 'object' }, 45 | }, 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | } 53 | 54 | const getPetOperation = sampleSpec.paths['/pets/{petId}']?.get as OpenAPIV3.OperationObject & { method: string; path: string } 55 | if (!getPetOperation) { 56 | throw new Error('Test setup error: getPet operation not found in sample spec') 57 | } 58 | 59 | beforeEach(async () => { 60 | // Create a new instance of HttpClient 61 | client = new HttpClient({ baseUrl: 'https://api.example.com' }, sampleSpec) 62 | // Await the initialization to ensure mockApi is set correctly 63 | mockApi = await client['api'] 64 | }) 65 | 66 | afterEach(() => { 67 | vi.clearAllMocks() 68 | }) 69 | 70 | it('successfully executes an operation', async () => { 71 | const mockResponse = { 72 | data: { id: 1, name: 'Fluffy' }, 73 | status: 200, 74 | headers: { 75 | 'content-type': 'application/json', 76 | }, 77 | } 78 | 79 | mockApi.getPet.mockResolvedValueOnce(mockResponse) 80 | 81 | const response = await client.executeOperation(getPetOperation, { petId: 1 }) 82 | 83 | // Note GET requests should have a null Content-Type header! 84 | expect(mockApi.getPet).toHaveBeenCalledWith({ petId: 1 }, undefined, { headers: { 'Content-Type': null } }) 85 | expect(response.data).toEqual(mockResponse.data) 86 | expect(response.status).toBe(200) 87 | expect(response.headers).toBeInstanceOf(Headers) 88 | expect(response.headers.get('content-type')).toBe('application/json') 89 | }) 90 | 91 | it('throws error when operation ID is missing', async () => { 92 | const operationWithoutId: OpenAPIV3.OperationObject & { method: string; path: string } = { 93 | method: 'GET', 94 | path: '/unknown', 95 | responses: { 96 | '200': { 97 | description: 'OK', 98 | }, 99 | }, 100 | } 101 | 102 | await expect(client.executeOperation(operationWithoutId)).rejects.toThrow('Operation ID is required') 103 | }) 104 | 105 | it('throws error when operation is not found', async () => { 106 | const operation: OpenAPIV3.OperationObject & { method: string; path: string } = { 107 | method: 'GET', 108 | path: '/unknown', 109 | operationId: 'nonexistentOperation', 110 | responses: { 111 | '200': { 112 | description: 'OK', 113 | }, 114 | }, 115 | } 116 | 117 | await expect(client.executeOperation(operation)).rejects.toThrow('Operation nonexistentOperation not found') 118 | }) 119 | 120 | it('handles API errors correctly', async () => { 121 | const error = { 122 | response: { 123 | status: 404, 124 | statusText: 'Not Found', 125 | data: { 126 | code: 'RESOURCE_NOT_FOUND', 127 | message: 'Pet not found', 128 | petId: 999, 129 | }, 130 | headers: { 131 | 'content-type': 'application/json', 132 | }, 133 | }, 134 | } 135 | mockApi.getPet.mockRejectedValueOnce(error) 136 | 137 | await expect(client.executeOperation(getPetOperation, { petId: 999 })).rejects.toMatchObject({ 138 | status: 404, 139 | message: '404 Not Found', 140 | data: { 141 | code: 'RESOURCE_NOT_FOUND', 142 | message: 'Pet not found', 143 | petId: 999, 144 | }, 145 | }) 146 | }) 147 | 148 | it('handles validation errors (400) correctly', async () => { 149 | const error = { 150 | response: { 151 | status: 400, 152 | statusText: 'Bad Request', 153 | data: { 154 | code: 'VALIDATION_ERROR', 155 | message: 'Invalid input data', 156 | errors: [ 157 | { 158 | field: 'age', 159 | message: 'Age must be a positive number', 160 | }, 161 | { 162 | field: 'name', 163 | message: 'Name is required', 164 | }, 165 | ], 166 | }, 167 | headers: { 168 | 'content-type': 'application/json', 169 | }, 170 | }, 171 | } 172 | mockApi.getPet.mockRejectedValueOnce(error) 173 | 174 | await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({ 175 | status: 400, 176 | message: '400 Bad Request', 177 | data: { 178 | code: 'VALIDATION_ERROR', 179 | message: 'Invalid input data', 180 | errors: [ 181 | { 182 | field: 'age', 183 | message: 'Age must be a positive number', 184 | }, 185 | { 186 | field: 'name', 187 | message: 'Name is required', 188 | }, 189 | ], 190 | }, 191 | }) 192 | }) 193 | 194 | it('handles server errors (500) with HTML response', async () => { 195 | const error = { 196 | response: { 197 | status: 500, 198 | statusText: 'Internal Server Error', 199 | data: '

500 Internal Server Error

', 200 | headers: { 201 | 'content-type': 'text/html', 202 | }, 203 | }, 204 | } 205 | mockApi.getPet.mockRejectedValueOnce(error) 206 | 207 | await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({ 208 | status: 500, 209 | message: '500 Internal Server Error', 210 | data: '

500 Internal Server Error

', 211 | }) 212 | }) 213 | 214 | it('handles rate limit errors (429)', async () => { 215 | const error = { 216 | response: { 217 | status: 429, 218 | statusText: 'Too Many Requests', 219 | data: { 220 | code: 'RATE_LIMIT_EXCEEDED', 221 | message: 'Rate limit exceeded', 222 | retryAfter: 60, 223 | }, 224 | headers: { 225 | 'content-type': 'application/json', 226 | 'retry-after': '60', 227 | }, 228 | }, 229 | } 230 | mockApi.getPet.mockRejectedValueOnce(error) 231 | 232 | await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({ 233 | status: 429, 234 | message: '429 Too Many Requests', 235 | data: { 236 | code: 'RATE_LIMIT_EXCEEDED', 237 | message: 'Rate limit exceeded', 238 | retryAfter: 60, 239 | }, 240 | }) 241 | }) 242 | 243 | it('should send body parameters in request body for POST operations', async () => { 244 | // Setup mock API with the new operation 245 | mockApi.testOperation = vi.fn().mockResolvedValue({ 246 | data: {}, 247 | status: 200, 248 | headers: {}, 249 | }) 250 | 251 | const testSpec: OpenAPIV3.Document = { 252 | openapi: '3.0.0', 253 | info: { title: 'Test API', version: '1.0.0' }, 254 | paths: { 255 | '/test': { 256 | post: { 257 | operationId: 'testOperation', 258 | requestBody: { 259 | content: { 260 | 'application/json': { 261 | schema: { 262 | type: 'object', 263 | properties: { 264 | foo: { type: 'string' }, 265 | }, 266 | }, 267 | }, 268 | }, 269 | }, 270 | responses: { 271 | '200': { 272 | description: 'Success response', 273 | content: { 274 | 'application/json': { 275 | schema: { 276 | type: 'object', 277 | }, 278 | }, 279 | }, 280 | }, 281 | }, 282 | }, 283 | }, 284 | }, 285 | } 286 | 287 | const postOperation = testSpec.paths['/test']?.post as OpenAPIV3.OperationObject & { method: string; path: string } 288 | if (!postOperation) { 289 | throw new Error('Test setup error: post operation not found') 290 | } 291 | 292 | const client = new HttpClient({ baseUrl: 'http://test.com' }, testSpec) 293 | 294 | await client.executeOperation(postOperation, { foo: 'bar' }) 295 | 296 | expect(mockApi.testOperation).toHaveBeenCalledWith({}, { foo: 'bar' }, { headers: { 'Content-Type': 'application/json' } }) 297 | }) 298 | 299 | it('should handle query, path, and body parameters correctly', async () => { 300 | mockApi.complexOperation = vi.fn().mockResolvedValue({ 301 | data: { success: true }, 302 | status: 200, 303 | headers: { 304 | 'content-type': 'application/json', 305 | }, 306 | }) 307 | 308 | const complexSpec: OpenAPIV3.Document = { 309 | openapi: '3.0.0', 310 | info: { title: 'Test API', version: '1.0.0' }, 311 | paths: { 312 | '/users/{userId}/posts': { 313 | post: { 314 | operationId: 'complexOperation', 315 | parameters: [ 316 | { 317 | name: 'userId', 318 | in: 'path', 319 | required: true, 320 | schema: { type: 'integer' }, 321 | }, 322 | { 323 | name: 'include', 324 | in: 'query', 325 | required: false, 326 | schema: { type: 'string' }, 327 | }, 328 | ], 329 | requestBody: { 330 | content: { 331 | 'application/json': { 332 | schema: { 333 | type: 'object', 334 | properties: { 335 | title: { type: 'string' }, 336 | content: { type: 'string' }, 337 | }, 338 | }, 339 | }, 340 | }, 341 | }, 342 | responses: { 343 | '200': { 344 | description: 'Success response', 345 | content: { 346 | 'application/json': { 347 | schema: { 348 | type: 'object', 349 | properties: { 350 | success: { type: 'boolean' }, 351 | }, 352 | }, 353 | }, 354 | }, 355 | }, 356 | }, 357 | }, 358 | }, 359 | }, 360 | } 361 | 362 | const complexOperation = complexSpec.paths['/users/{userId}/posts']?.post as OpenAPIV3.OperationObject & { 363 | method: string 364 | path: string 365 | } 366 | if (!complexOperation) { 367 | throw new Error('Test setup error: complex operation not found') 368 | } 369 | 370 | const client = new HttpClient({ baseUrl: 'http://test.com' }, complexSpec) 371 | 372 | await client.executeOperation(complexOperation, { 373 | // Path parameter 374 | userId: 123, 375 | // Query parameter 376 | include: 'comments', 377 | // Body parameters 378 | title: 'Test Post', 379 | content: 'Test Content', 380 | }) 381 | 382 | expect(mockApi.complexOperation).toHaveBeenCalledWith( 383 | { 384 | userId: 123, 385 | include: 'comments', 386 | }, 387 | { 388 | title: 'Test Post', 389 | content: 'Test Content', 390 | }, 391 | { headers: { 'Content-Type': 'application/json' } }, 392 | ) 393 | }) 394 | 395 | const mockOpenApiSpec: OpenAPIV3.Document = { 396 | openapi: '3.0.0', 397 | info: { title: 'Test API', version: '1.0.0' }, 398 | paths: { 399 | '/test': { 400 | post: { 401 | operationId: 'testOperation', 402 | parameters: [ 403 | { 404 | name: 'queryParam', 405 | in: 'query', 406 | schema: { type: 'string' }, 407 | }, 408 | { 409 | name: 'pathParam', 410 | in: 'path', 411 | schema: { type: 'string' }, 412 | }, 413 | ], 414 | requestBody: { 415 | content: { 416 | 'application/json': { 417 | schema: { 418 | type: 'object', 419 | properties: { 420 | bodyParam: { type: 'string' }, 421 | }, 422 | }, 423 | }, 424 | }, 425 | }, 426 | responses: { 427 | '200': { 428 | description: 'Success', 429 | }, 430 | '400': { 431 | description: 'Bad Request', 432 | }, 433 | }, 434 | }, 435 | }, 436 | }, 437 | } 438 | 439 | const mockConfig = { 440 | baseUrl: 'http://test-api.com', 441 | } 442 | 443 | beforeEach(() => { 444 | vi.clearAllMocks() 445 | }) 446 | 447 | it('should properly propagate structured error responses', async () => { 448 | const errorResponse = { 449 | response: { 450 | data: { 451 | code: 'VALIDATION_ERROR', 452 | message: 'Invalid input', 453 | details: ['Field x is required'], 454 | }, 455 | status: 400, 456 | statusText: 'Bad Request', 457 | headers: { 458 | 'content-type': 'application/json', 459 | }, 460 | }, 461 | } 462 | 463 | // Mock axios instance 464 | const mockAxiosInstance = { 465 | testOperation: vi.fn().mockRejectedValue(errorResponse), 466 | } 467 | 468 | // Mock the OpenAPIClientAxios initialization 469 | const MockOpenAPIClientAxios = vi.fn().mockImplementation(() => ({ 470 | init: () => Promise.resolve(mockAxiosInstance), 471 | })) 472 | 473 | vi.mocked(OpenAPIClientAxios).mockImplementation(() => MockOpenAPIClientAxios()) 474 | 475 | const client = new HttpClient(mockConfig, mockOpenApiSpec) 476 | const operation = mockOpenApiSpec.paths['/test']?.post 477 | if (!operation) { 478 | throw new Error('Operation not found in mock spec') 479 | } 480 | 481 | try { 482 | await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, {}) 483 | // Should not reach here 484 | expect(true).toBe(false) 485 | } catch (error: any) { 486 | expect(error.status).toBe(400) 487 | expect(error.data).toEqual({ 488 | code: 'VALIDATION_ERROR', 489 | message: 'Invalid input', 490 | details: ['Field x is required'], 491 | }) 492 | expect(error.message).toBe('400 Bad Request') 493 | } 494 | }) 495 | 496 | it('should handle query, path, and body parameters correctly', async () => { 497 | const mockAxiosInstance = { 498 | testOperation: vi.fn().mockResolvedValue({ 499 | data: { success: true }, 500 | status: 200, 501 | headers: { 'content-type': 'application/json' }, 502 | }), 503 | } 504 | 505 | const MockOpenAPIClientAxios = vi.fn().mockImplementation(() => ({ 506 | init: () => Promise.resolve(mockAxiosInstance), 507 | })) 508 | 509 | vi.mocked(OpenAPIClientAxios).mockImplementation(() => MockOpenAPIClientAxios()) 510 | 511 | const client = new HttpClient(mockConfig, mockOpenApiSpec) 512 | const operation = mockOpenApiSpec.paths['/test']?.post 513 | if (!operation) { 514 | throw new Error('Operation not found in mock spec') 515 | } 516 | 517 | const response = await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, { 518 | queryParam: 'query1', 519 | pathParam: 'path1', 520 | bodyParam: 'body1', 521 | }) 522 | 523 | expect(mockAxiosInstance.testOperation).toHaveBeenCalledWith( 524 | { 525 | queryParam: 'query1', 526 | pathParam: 'path1', 527 | }, 528 | { 529 | bodyParam: 'body1', 530 | }, 531 | { headers: { 'Content-Type': 'application/json' } }, 532 | ) 533 | 534 | // Additional check to ensure headers are correctly processed 535 | expect(response.headers.get('content-type')).toBe('application/json') 536 | }) 537 | }) 538 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/client/http-client.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' 2 | import OpenAPIClientAxios from 'openapi-client-axios' 3 | import type { AxiosInstance } from 'axios' 4 | import FormData from 'form-data' 5 | import fs from 'fs' 6 | import { Headers } from './polyfill-headers' 7 | import { isFileUploadParameter } from '../openapi/file-upload' 8 | 9 | export type HttpClientConfig = { 10 | baseUrl: string 11 | headers?: Record 12 | } 13 | 14 | export type HttpClientResponse = { 15 | data: T 16 | status: number 17 | headers: Headers 18 | } 19 | 20 | export class HttpClientError extends Error { 21 | constructor( 22 | message: string, 23 | public status: number, 24 | public data: any, 25 | public headers?: Headers, 26 | ) { 27 | super(`${status} ${message}`) 28 | this.name = 'HttpClientError' 29 | } 30 | } 31 | 32 | export class HttpClient { 33 | private api: Promise 34 | private client: OpenAPIClientAxios 35 | 36 | constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) { 37 | // @ts-expect-error 38 | this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({ 39 | definition: openApiSpec, 40 | axiosConfigDefaults: { 41 | baseURL: config.baseUrl, 42 | headers: { 43 | 'Content-Type': 'application/json', 44 | 'User-Agent': 'notion-mcp-server', 45 | ...config.headers, 46 | }, 47 | }, 48 | }) 49 | this.api = this.client.init() 50 | } 51 | 52 | private async prepareFileUpload(operation: OpenAPIV3.OperationObject, params: Record): Promise { 53 | const fileParams = isFileUploadParameter(operation) 54 | if (fileParams.length === 0) return null 55 | 56 | const formData = new FormData() 57 | 58 | // Handle file uploads 59 | for (const param of fileParams) { 60 | const filePath = params[param] 61 | if (!filePath) { 62 | throw new Error(`File path must be provided for parameter: ${param}`) 63 | } 64 | switch (typeof filePath) { 65 | case 'string': 66 | addFile(param, filePath) 67 | break 68 | case 'object': 69 | if(Array.isArray(filePath)) { 70 | let fileCount = 0 71 | for(const file of filePath) { 72 | addFile(param, file) 73 | fileCount++ 74 | } 75 | break 76 | } 77 | //deliberate fallthrough 78 | default: 79 | throw new Error(`Unsupported file type: ${typeof filePath}`) 80 | } 81 | function addFile(name: string, filePath: string) { 82 | try { 83 | const fileStream = fs.createReadStream(filePath) 84 | formData.append(name, fileStream) 85 | } catch (error) { 86 | throw new Error(`Failed to read file at ${filePath}: ${error}`) 87 | } 88 | } 89 | } 90 | 91 | // Add non-file parameters to form data 92 | for (const [key, value] of Object.entries(params)) { 93 | if (!fileParams.includes(key)) { 94 | formData.append(key, value) 95 | } 96 | } 97 | 98 | return formData 99 | } 100 | 101 | /** 102 | * Execute an OpenAPI operation 103 | */ 104 | async executeOperation( 105 | operation: OpenAPIV3.OperationObject & { method: string; path: string }, 106 | params: Record = {}, 107 | ): Promise> { 108 | const api = await this.api 109 | const operationId = operation.operationId 110 | if (!operationId) { 111 | throw new Error('Operation ID is required') 112 | } 113 | 114 | // Handle file uploads if present 115 | const formData = await this.prepareFileUpload(operation, params) 116 | 117 | // Separate parameters based on their location 118 | const urlParameters: Record = {} 119 | const bodyParams: Record = formData || { ...params } 120 | 121 | // Extract path and query parameters based on operation definition 122 | if (operation.parameters) { 123 | for (const param of operation.parameters) { 124 | if ('name' in param && param.name && param.in) { 125 | if (param.in === 'path' || param.in === 'query') { 126 | if (params[param.name] !== undefined) { 127 | urlParameters[param.name] = params[param.name] 128 | if (!formData) { 129 | delete bodyParams[param.name] 130 | } 131 | } 132 | } 133 | } 134 | } 135 | } 136 | 137 | // Add all parameters as url parameters if there is no requestBody defined 138 | if (!operation.requestBody && !formData) { 139 | for (const key in bodyParams) { 140 | if (bodyParams[key] !== undefined) { 141 | urlParameters[key] = bodyParams[key] 142 | delete bodyParams[key] 143 | } 144 | } 145 | } 146 | 147 | const operationFn = (api as any)[operationId] 148 | if (!operationFn) { 149 | throw new Error(`Operation ${operationId} not found`) 150 | } 151 | 152 | try { 153 | // If we have form data, we need to set the correct headers 154 | const hasBody = Object.keys(bodyParams).length > 0 155 | const headers = formData 156 | ? formData.getHeaders() 157 | : { ...(hasBody ? { 'Content-Type': 'application/json' } : { 'Content-Type': null }) } 158 | const requestConfig = { 159 | headers: { 160 | ...headers, 161 | }, 162 | } 163 | 164 | // first argument is url parameters, second is body parameters 165 | const response = await operationFn(urlParameters, hasBody ? bodyParams : undefined, requestConfig) 166 | 167 | // Convert axios headers to Headers object 168 | const responseHeaders = new Headers() 169 | Object.entries(response.headers).forEach(([key, value]) => { 170 | if (value) responseHeaders.append(key, value.toString()) 171 | }) 172 | 173 | return { 174 | data: response.data, 175 | status: response.status, 176 | headers: responseHeaders, 177 | } 178 | } catch (error: any) { 179 | if (error.response) { 180 | console.error('Error in http client', error) 181 | const headers = new Headers() 182 | Object.entries(error.response.headers).forEach(([key, value]) => { 183 | if (value) headers.append(key, value.toString()) 184 | }) 185 | 186 | throw new HttpClientError(error.response.statusText || 'Request failed', error.response.status, error.response.data, headers) 187 | } 188 | throw error 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/client/polyfill-headers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * The Headers class was supported in Node.js starting with version 18, which was released on April 19, 2022. 3 | * We need to have a polyfill ready to work for old Node versions. 4 | * See more at https://github.com/makenotion/notion-mcp-server/issues/32 5 | * */ 6 | class PolyfillHeaders { 7 | private headers: Map = new Map(); 8 | 9 | constructor(init?: Record) { 10 | if (init) { 11 | Object.entries(init).forEach(([key, value]) => { 12 | this.append(key, value); 13 | }); 14 | } 15 | } 16 | 17 | public append(name: string, value: string): void { 18 | const key = name.toLowerCase(); 19 | 20 | if (!this.headers.has(key)) { 21 | this.headers.set(key, []); 22 | } 23 | 24 | this.headers.get(key)!.push(value); 25 | } 26 | 27 | public get(name: string): string | null { 28 | const key = name.toLowerCase(); 29 | 30 | if (!this.headers.has(key)) { 31 | return null; 32 | } 33 | 34 | return this.headers.get(key)!.join(', '); 35 | } 36 | } 37 | 38 | const GlobalHeaders = typeof global !== 'undefined' && 'Headers' in global 39 | ? (global as any).Headers 40 | : undefined; 41 | 42 | export const Headers = (GlobalHeaders || PolyfillHeaders); 43 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/index.ts: -------------------------------------------------------------------------------- 1 | export { OpenAPIToMCPConverter } from './openapi/parser' 2 | export { HttpClient } from './client/http-client' 3 | export type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' 4 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/mcp/__tests__/proxy.test.ts: -------------------------------------------------------------------------------- 1 | import { MCPProxy } from '../proxy' 2 | import { OpenAPIV3 } from 'openapi-types' 3 | import { HttpClient } from '../../client/http-client' 4 | import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' 5 | import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' 6 | 7 | // Mock the dependencies 8 | vi.mock('../../client/http-client') 9 | vi.mock('@modelcontextprotocol/sdk/server/index.js') 10 | 11 | describe('MCPProxy', () => { 12 | let proxy: MCPProxy 13 | let mockOpenApiSpec: OpenAPIV3.Document 14 | 15 | beforeEach(() => { 16 | // Reset all mocks 17 | vi.clearAllMocks() 18 | 19 | // Setup minimal OpenAPI spec for testing 20 | mockOpenApiSpec = { 21 | openapi: '3.0.0', 22 | servers: [{ url: 'http://localhost:3000' }], 23 | info: { 24 | title: 'Test API', 25 | version: '1.0.0', 26 | }, 27 | paths: { 28 | '/test': { 29 | get: { 30 | operationId: 'getTest', 31 | responses: { 32 | '200': { 33 | description: 'Success', 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | } 40 | 41 | proxy = new MCPProxy('test-proxy', mockOpenApiSpec) 42 | }) 43 | 44 | describe('listTools handler', () => { 45 | it('should return converted tools from OpenAPI spec', async () => { 46 | const server = (proxy as any).server 47 | const listToolsHandler = server.setRequestHandler.mock.calls[0].filter((x: unknown) => typeof x === 'function')[0] 48 | const result = await listToolsHandler() 49 | 50 | expect(result).toHaveProperty('tools') 51 | expect(Array.isArray(result.tools)).toBe(true) 52 | }) 53 | 54 | it('should truncate tool names exceeding 64 characters', async () => { 55 | // Setup OpenAPI spec with long tool names 56 | mockOpenApiSpec.paths = { 57 | '/test': { 58 | get: { 59 | operationId: 'a'.repeat(65), 60 | responses: { 61 | '200': { 62 | description: 'Success' 63 | } 64 | } 65 | } 66 | } 67 | } 68 | proxy = new MCPProxy('test-proxy', mockOpenApiSpec) 69 | const server = (proxy as any).server 70 | const listToolsHandler = server.setRequestHandler.mock.calls[0].filter((x: unknown) => typeof x === 'function')[0]; 71 | const result = await listToolsHandler() 72 | 73 | expect(result.tools[0].name.length).toBeLessThanOrEqual(64) 74 | }) 75 | }) 76 | 77 | describe('callTool handler', () => { 78 | it('should execute operation and return formatted response', async () => { 79 | // Mock HttpClient response 80 | const mockResponse = { 81 | data: { message: 'success' }, 82 | status: 200, 83 | headers: new Headers({ 84 | 'content-type': 'application/json', 85 | }), 86 | } 87 | ;(HttpClient.prototype.executeOperation as ReturnType).mockResolvedValue(mockResponse) 88 | 89 | // Set up the openApiLookup with our test operation 90 | ;(proxy as any).openApiLookup = { 91 | 'API-getTest': { 92 | operationId: 'getTest', 93 | responses: { '200': { description: 'Success' } }, 94 | method: 'get', 95 | path: '/test', 96 | }, 97 | } 98 | 99 | const server = (proxy as any).server 100 | const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function') 101 | const callToolHandler = handlers[1] 102 | 103 | const result = await callToolHandler({ 104 | params: { 105 | name: 'API-getTest', 106 | arguments: {}, 107 | }, 108 | }) 109 | 110 | expect(result).toEqual({ 111 | content: [ 112 | { 113 | type: 'text', 114 | text: JSON.stringify({ message: 'success' }), 115 | }, 116 | ], 117 | }) 118 | }) 119 | 120 | it('should throw error for non-existent operation', async () => { 121 | const server = (proxy as any).server 122 | const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function') 123 | const callToolHandler = handlers[1] 124 | 125 | await expect( 126 | callToolHandler({ 127 | params: { 128 | name: 'nonExistentMethod', 129 | arguments: {}, 130 | }, 131 | }), 132 | ).rejects.toThrow('Method nonExistentMethod not found') 133 | }) 134 | 135 | it('should handle tool names exceeding 64 characters', async () => { 136 | // Mock HttpClient response 137 | const mockResponse = { 138 | data: { message: 'success' }, 139 | status: 200, 140 | headers: new Headers({ 141 | 'content-type': 'application/json' 142 | }) 143 | }; 144 | (HttpClient.prototype.executeOperation as ReturnType).mockResolvedValue(mockResponse); 145 | 146 | // Set up the openApiLookup with a long tool name 147 | const longToolName = 'a'.repeat(65) 148 | const truncatedToolName = longToolName.slice(0, 64) 149 | ;(proxy as any).openApiLookup = { 150 | [truncatedToolName]: { 151 | operationId: longToolName, 152 | responses: { '200': { description: 'Success' } }, 153 | method: 'get', 154 | path: '/test' 155 | } 156 | }; 157 | 158 | const server = (proxy as any).server; 159 | const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function'); 160 | const callToolHandler = handlers[1]; 161 | 162 | const result = await callToolHandler({ 163 | params: { 164 | name: truncatedToolName, 165 | arguments: {} 166 | } 167 | }) 168 | 169 | expect(result).toEqual({ 170 | content: [ 171 | { 172 | type: 'text', 173 | text: JSON.stringify({ message: 'success' }) 174 | } 175 | ] 176 | }) 177 | }) 178 | }) 179 | 180 | describe('getContentType', () => { 181 | it('should return correct content type for different headers', () => { 182 | const getContentType = (proxy as any).getContentType.bind(proxy) 183 | 184 | expect(getContentType(new Headers({ 'content-type': 'text/plain' }))).toBe('text') 185 | expect(getContentType(new Headers({ 'content-type': 'application/json' }))).toBe('text') 186 | expect(getContentType(new Headers({ 'content-type': 'image/jpeg' }))).toBe('image') 187 | expect(getContentType(new Headers({ 'content-type': 'application/octet-stream' }))).toBe('binary') 188 | expect(getContentType(new Headers())).toBe('binary') 189 | }) 190 | }) 191 | 192 | describe('parseHeadersFromEnv', () => { 193 | const originalEnv = process.env 194 | 195 | beforeEach(() => { 196 | process.env = { ...originalEnv } 197 | }) 198 | 199 | afterEach(() => { 200 | process.env = originalEnv 201 | }) 202 | 203 | it('should parse valid JSON headers from env', () => { 204 | process.env.OPENAPI_MCP_HEADERS = JSON.stringify({ 205 | Authorization: 'Bearer token123', 206 | 'X-Custom-Header': 'test', 207 | }) 208 | 209 | const proxy = new MCPProxy('test-proxy', mockOpenApiSpec) 210 | expect(HttpClient).toHaveBeenCalledWith( 211 | expect.objectContaining({ 212 | headers: { 213 | Authorization: 'Bearer token123', 214 | 'X-Custom-Header': 'test', 215 | }, 216 | }), 217 | expect.anything(), 218 | ) 219 | }) 220 | 221 | it('should return empty object when env var is not set', () => { 222 | delete process.env.OPENAPI_MCP_HEADERS 223 | 224 | const proxy = new MCPProxy('test-proxy', mockOpenApiSpec) 225 | expect(HttpClient).toHaveBeenCalledWith( 226 | expect.objectContaining({ 227 | headers: {}, 228 | }), 229 | expect.anything(), 230 | ) 231 | }) 232 | 233 | it('should return empty object and warn on invalid JSON', () => { 234 | const consoleSpy = vi.spyOn(console, 'warn') 235 | process.env.OPENAPI_MCP_HEADERS = 'invalid json' 236 | 237 | const proxy = new MCPProxy('test-proxy', mockOpenApiSpec) 238 | expect(HttpClient).toHaveBeenCalledWith( 239 | expect.objectContaining({ 240 | headers: {}, 241 | }), 242 | expect.anything(), 243 | ) 244 | expect(consoleSpy).toHaveBeenCalledWith('Failed to parse OPENAPI_MCP_HEADERS environment variable:', expect.any(Error)) 245 | }) 246 | 247 | it('should return empty object and warn on non-object JSON', () => { 248 | const consoleSpy = vi.spyOn(console, 'warn') 249 | process.env.OPENAPI_MCP_HEADERS = '"string"' 250 | 251 | const proxy = new MCPProxy('test-proxy', mockOpenApiSpec) 252 | expect(HttpClient).toHaveBeenCalledWith( 253 | expect.objectContaining({ 254 | headers: {}, 255 | }), 256 | expect.anything(), 257 | ) 258 | expect(consoleSpy).toHaveBeenCalledWith('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', 'string') 259 | }) 260 | }) 261 | describe('connect', () => { 262 | it('should connect to transport', async () => { 263 | const mockTransport = {} as Transport 264 | await proxy.connect(mockTransport) 265 | 266 | const server = (proxy as any).server 267 | expect(server.connect).toHaveBeenCalledWith(mockTransport) 268 | }) 269 | }) 270 | }) 271 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/mcp/proxy.ts: -------------------------------------------------------------------------------- 1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js' 2 | import { CallToolRequestSchema, JSONRPCResponse, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js' 3 | import { JSONSchema7 as IJsonSchema } from 'json-schema' 4 | import { OpenAPIToMCPConverter } from '../openapi/parser' 5 | import { HttpClient, HttpClientError } from '../client/http-client' 6 | import { OpenAPIV3 } from 'openapi-types' 7 | import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' 8 | 9 | type PathItemObject = OpenAPIV3.PathItemObject & { 10 | get?: OpenAPIV3.OperationObject 11 | put?: OpenAPIV3.OperationObject 12 | post?: OpenAPIV3.OperationObject 13 | delete?: OpenAPIV3.OperationObject 14 | patch?: OpenAPIV3.OperationObject 15 | } 16 | 17 | type NewToolDefinition = { 18 | methods: Array<{ 19 | name: string 20 | description: string 21 | inputSchema: IJsonSchema & { type: 'object' } 22 | returnSchema?: IJsonSchema 23 | }> 24 | } 25 | 26 | // import this class, extend and return server 27 | export class MCPProxy { 28 | private server: Server 29 | private httpClient: HttpClient 30 | private tools: Record 31 | private openApiLookup: Record 32 | 33 | constructor(name: string, openApiSpec: OpenAPIV3.Document) { 34 | this.server = new Server({ name, version: '1.0.0' }, { capabilities: { tools: {} } }) 35 | const baseUrl = openApiSpec.servers?.[0].url 36 | if (!baseUrl) { 37 | throw new Error('No base URL found in OpenAPI spec') 38 | } 39 | this.httpClient = new HttpClient( 40 | { 41 | baseUrl, 42 | headers: this.parseHeadersFromEnv(), 43 | }, 44 | openApiSpec, 45 | ) 46 | 47 | // Convert OpenAPI spec to MCP tools 48 | const converter = new OpenAPIToMCPConverter(openApiSpec) 49 | const { tools, openApiLookup } = converter.convertToMCPTools() 50 | this.tools = tools 51 | this.openApiLookup = openApiLookup 52 | 53 | this.setupHandlers() 54 | } 55 | 56 | private setupHandlers() { 57 | // Handle tool listing 58 | this.server.setRequestHandler(ListToolsRequestSchema, async () => { 59 | const tools: Tool[] = [] 60 | 61 | // Add methods as separate tools to match the MCP format 62 | Object.entries(this.tools).forEach(([toolName, def]) => { 63 | def.methods.forEach(method => { 64 | const toolNameWithMethod = `${toolName}-${method.name}`; 65 | const truncatedToolName = this.truncateToolName(toolNameWithMethod); 66 | tools.push({ 67 | name: truncatedToolName, 68 | description: method.description, 69 | inputSchema: method.inputSchema as Tool['inputSchema'], 70 | }) 71 | }) 72 | }) 73 | 74 | return { tools } 75 | }) 76 | 77 | // Handle tool calling 78 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 79 | const { name, arguments: params } = request.params 80 | 81 | // Find the operation in OpenAPI spec 82 | const operation = this.findOperation(name) 83 | if (!operation) { 84 | throw new Error(`Method ${name} not found`) 85 | } 86 | 87 | try { 88 | // Execute the operation 89 | const response = await this.httpClient.executeOperation(operation, params) 90 | 91 | // Convert response to MCP format 92 | return { 93 | content: [ 94 | { 95 | type: 'text', // currently this is the only type that seems to be used by mcp server 96 | text: JSON.stringify(response.data), // TODO: pass through the http status code text? 97 | }, 98 | ], 99 | } 100 | } catch (error) { 101 | console.error('Error in tool call', error) 102 | if (error instanceof HttpClientError) { 103 | console.error('HttpClientError encountered, returning structured error', error) 104 | const data = error.data?.response?.data ?? error.data ?? {} 105 | return { 106 | content: [ 107 | { 108 | type: 'text', 109 | text: JSON.stringify({ 110 | status: 'error', // TODO: get this from http status code? 111 | ...(typeof data === 'object' ? data : { data: data }), 112 | }), 113 | }, 114 | ], 115 | } 116 | } 117 | throw error 118 | } 119 | }) 120 | } 121 | 122 | private findOperation(operationId: string): (OpenAPIV3.OperationObject & { method: string; path: string }) | null { 123 | return this.openApiLookup[operationId] ?? null 124 | } 125 | 126 | private parseHeadersFromEnv(): Record { 127 | const headersJson = process.env.OPENAPI_MCP_HEADERS 128 | if (!headersJson) { 129 | return {} 130 | } 131 | 132 | try { 133 | const headers = JSON.parse(headersJson) 134 | if (typeof headers !== 'object' || headers === null) { 135 | console.warn('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', typeof headers) 136 | return {} 137 | } 138 | return headers 139 | } catch (error) { 140 | console.warn('Failed to parse OPENAPI_MCP_HEADERS environment variable:', error) 141 | return {} 142 | } 143 | } 144 | 145 | private getContentType(headers: Headers): 'text' | 'image' | 'binary' { 146 | const contentType = headers.get('content-type') 147 | if (!contentType) return 'binary' 148 | 149 | if (contentType.includes('text') || contentType.includes('json')) { 150 | return 'text' 151 | } else if (contentType.includes('image')) { 152 | return 'image' 153 | } 154 | return 'binary' 155 | } 156 | 157 | private truncateToolName(name: string): string { 158 | if (name.length <= 64) { 159 | return name; 160 | } 161 | return name.slice(0, 64); 162 | } 163 | 164 | async connect(transport: Transport) { 165 | // The SDK will handle stdio communication 166 | await this.server.connect(transport) 167 | } 168 | 169 | getServer() { 170 | return this.server 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/openapi/__tests__/file-upload.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types' 2 | import { describe, it, expect } from 'vitest' 3 | import { isFileUploadParameter } from '../file-upload' 4 | 5 | describe('File Upload Detection', () => { 6 | it('identifies file upload parameters in request bodies', () => { 7 | const operation: OpenAPIV3.OperationObject = { 8 | operationId: 'uploadFile', 9 | responses: { 10 | '200': { 11 | description: 'File uploaded successfully', 12 | }, 13 | }, 14 | requestBody: { 15 | content: { 16 | 'multipart/form-data': { 17 | schema: { 18 | type: 'object', 19 | properties: { 20 | file: { 21 | type: 'string', 22 | format: 'binary', 23 | }, 24 | additionalInfo: { 25 | type: 'string', 26 | }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | } 33 | 34 | const fileParams = isFileUploadParameter(operation) 35 | expect(fileParams).toEqual(['file']) 36 | }) 37 | 38 | it('returns empty array for non-file upload operations', () => { 39 | const operation: OpenAPIV3.OperationObject = { 40 | operationId: 'createUser', 41 | responses: { 42 | '200': { 43 | description: 'User created successfully', 44 | }, 45 | }, 46 | requestBody: { 47 | content: { 48 | 'application/json': { 49 | schema: { 50 | type: 'object', 51 | properties: { 52 | name: { 53 | type: 'string', 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | } 61 | 62 | const fileParams = isFileUploadParameter(operation) 63 | expect(fileParams).toEqual([]) 64 | }) 65 | 66 | it('identifies array-based file upload parameters', () => { 67 | const operation: OpenAPIV3.OperationObject = { 68 | operationId: 'uploadFiles', 69 | responses: { 70 | '200': { 71 | description: 'Files uploaded successfully', 72 | }, 73 | }, 74 | requestBody: { 75 | content: { 76 | 'multipart/form-data': { 77 | schema: { 78 | type: 'object', 79 | properties: { 80 | files: { 81 | type: 'array', 82 | items: { 83 | type: 'string', 84 | format: 'binary', 85 | }, 86 | }, 87 | description: { 88 | type: 'string', 89 | }, 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | } 96 | 97 | const fileParams = isFileUploadParameter(operation) 98 | expect(fileParams).toEqual(['files']) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types' 2 | import { describe, it, expect } from 'vitest' 3 | import { OpenAPIToMCPConverter } from '../parser' 4 | 5 | describe('OpenAPI Multipart Form Parser', () => { 6 | it('converts single file upload endpoint to tool', () => { 7 | const spec: OpenAPIV3.Document = { 8 | openapi: '3.0.0', 9 | info: { title: 'Test API', version: '1.0.0' }, 10 | paths: { 11 | '/pets/{id}/photo': { 12 | post: { 13 | operationId: 'uploadPetPhoto', 14 | summary: 'Upload a photo for a pet', 15 | parameters: [ 16 | { 17 | name: 'id', 18 | in: 'path', 19 | required: true, 20 | schema: { type: 'integer' }, 21 | }, 22 | ], 23 | requestBody: { 24 | required: true, 25 | content: { 26 | 'multipart/form-data': { 27 | schema: { 28 | type: 'object', 29 | required: ['photo'], 30 | properties: { 31 | photo: { 32 | type: 'string', 33 | format: 'binary', 34 | description: 'The photo to upload', 35 | }, 36 | caption: { 37 | type: 'string', 38 | description: 'Optional caption for the photo', 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | }, 45 | responses: { 46 | '201': { 47 | description: 'Photo uploaded successfully', 48 | }, 49 | }, 50 | }, 51 | }, 52 | }, 53 | } 54 | 55 | const converter = new OpenAPIToMCPConverter(spec) 56 | const { tools } = converter.convertToMCPTools() 57 | expect(Object.keys(tools)).toHaveLength(1) 58 | 59 | const [tool] = Object.values(tools) 60 | expect(tool.methods).toHaveLength(1) 61 | const [method] = tool.methods 62 | expect(method.name).toBe('uploadPetPhoto') 63 | expect(method.description).toContain('Upload a photo for a pet') 64 | 65 | // Check parameters 66 | expect(method.inputSchema.properties).toEqual({ 67 | id: { 68 | type: 'integer', 69 | }, 70 | photo: { 71 | type: 'string', 72 | format: 'uri-reference', 73 | description: expect.stringContaining('The photo to upload (absolute paths to local files)'), 74 | }, 75 | caption: { 76 | type: 'string', 77 | description: expect.stringContaining('Optional caption'), 78 | }, 79 | }) 80 | 81 | expect(method.inputSchema.required).toContain('id') 82 | expect(method.inputSchema.required).toContain('photo') 83 | expect(method.inputSchema.required).not.toContain('caption') 84 | }) 85 | 86 | it('converts multiple file upload endpoint to tool', () => { 87 | const spec: OpenAPIV3.Document = { 88 | openapi: '3.0.0', 89 | info: { title: 'Test API', version: '1.0.0' }, 90 | paths: { 91 | '/pets/{id}/documents': { 92 | post: { 93 | operationId: 'uploadPetDocuments', 94 | summary: 'Upload multiple documents for a pet', 95 | parameters: [ 96 | { 97 | name: 'id', 98 | in: 'path', 99 | required: true, 100 | schema: { type: 'integer' }, 101 | }, 102 | ], 103 | requestBody: { 104 | required: true, 105 | content: { 106 | 'multipart/form-data': { 107 | schema: { 108 | type: 'object', 109 | required: ['documents'], 110 | properties: { 111 | documents: { 112 | type: 'array', 113 | items: { 114 | type: 'string', 115 | format: 'binary', 116 | }, 117 | description: 'The documents to upload (max 5 files)', 118 | }, 119 | tags: { 120 | type: 'array', 121 | items: { 122 | type: 'string', 123 | }, 124 | description: 'Optional tags for the documents', 125 | }, 126 | }, 127 | }, 128 | }, 129 | }, 130 | }, 131 | responses: { 132 | '201': { 133 | description: 'Documents uploaded successfully', 134 | }, 135 | }, 136 | }, 137 | }, 138 | }, 139 | } 140 | 141 | const converter = new OpenAPIToMCPConverter(spec) 142 | const { tools } = converter.convertToMCPTools() 143 | expect(Object.keys(tools)).toHaveLength(1) 144 | 145 | const [tool] = Object.values(tools) 146 | expect(tool.methods).toHaveLength(1) 147 | const [method] = tool.methods 148 | expect(method.name).toBe('uploadPetDocuments') 149 | expect(method.description).toContain('Upload multiple documents') 150 | 151 | // Check parameters 152 | expect(method.inputSchema.properties).toEqual({ 153 | id: { 154 | type: 'integer', 155 | }, 156 | documents: { 157 | type: 'array', 158 | items: { 159 | type: 'string', 160 | format: 'uri-reference', 161 | description: 'absolute paths to local files', 162 | }, 163 | description: expect.stringContaining('max 5 files'), 164 | }, 165 | tags: { 166 | type: 'array', 167 | items: { 168 | type: 'string', 169 | }, 170 | description: expect.stringContaining('Optional tags'), 171 | }, 172 | }) 173 | 174 | expect(method.inputSchema.required).toContain('id') 175 | expect(method.inputSchema.required).toContain('documents') 176 | expect(method.inputSchema.required).not.toContain('tags') 177 | }) 178 | 179 | it('handles complex multipart forms with mixed content', () => { 180 | const spec: OpenAPIV3.Document = { 181 | openapi: '3.0.0', 182 | info: { title: 'Test API', version: '1.0.0' }, 183 | paths: { 184 | '/pets/{id}/profile': { 185 | post: { 186 | operationId: 'updatePetProfile', 187 | summary: 'Update pet profile with images and data', 188 | parameters: [ 189 | { 190 | name: 'id', 191 | in: 'path', 192 | required: true, 193 | schema: { type: 'integer' }, 194 | }, 195 | ], 196 | requestBody: { 197 | required: true, 198 | content: { 199 | 'multipart/form-data': { 200 | schema: { 201 | type: 'object', 202 | required: ['avatar', 'details'], 203 | properties: { 204 | avatar: { 205 | type: 'string', 206 | format: 'binary', 207 | description: 'Profile picture', 208 | }, 209 | gallery: { 210 | type: 'array', 211 | items: { 212 | type: 'string', 213 | format: 'binary', 214 | }, 215 | description: 'Additional pet photos', 216 | }, 217 | details: { 218 | type: 'object', 219 | properties: { 220 | name: { type: 'string' }, 221 | age: { type: 'integer' }, 222 | breed: { type: 'string' }, 223 | }, 224 | }, 225 | preferences: { 226 | type: 'array', 227 | items: { 228 | type: 'object', 229 | properties: { 230 | category: { type: 'string' }, 231 | value: { type: 'string' }, 232 | }, 233 | }, 234 | }, 235 | }, 236 | }, 237 | }, 238 | }, 239 | }, 240 | responses: { 241 | '200': { 242 | description: 'Profile updated successfully', 243 | }, 244 | }, 245 | }, 246 | }, 247 | }, 248 | } 249 | 250 | const converter = new OpenAPIToMCPConverter(spec) 251 | const { tools } = converter.convertToMCPTools() 252 | expect(Object.keys(tools)).toHaveLength(1) 253 | 254 | const [tool] = Object.values(tools) 255 | expect(tool.methods).toHaveLength(1) 256 | const [method] = tool.methods 257 | expect(method.name).toBe('updatePetProfile') 258 | expect(method.description).toContain('Update pet profile') 259 | 260 | // Check parameters 261 | expect(method.inputSchema.properties).toEqual({ 262 | id: { 263 | type: 'integer', 264 | }, 265 | avatar: { 266 | type: 'string', 267 | format: 'uri-reference', 268 | description: expect.stringContaining('Profile picture (absolute paths to local files)'), 269 | }, 270 | gallery: { 271 | type: 'array', 272 | items: { 273 | type: 'string', 274 | format: 'uri-reference', 275 | description: 'absolute paths to local files', 276 | }, 277 | description: expect.stringContaining('Additional pet photos'), 278 | }, 279 | details: { 280 | type: 'object', 281 | properties: { 282 | name: { type: 'string' }, 283 | age: { type: 'integer' }, 284 | breed: { type: 'string' }, 285 | }, 286 | additionalProperties: true, 287 | }, 288 | preferences: { 289 | type: 'array', 290 | items: { 291 | type: 'object', 292 | properties: { 293 | category: { type: 'string' }, 294 | value: { type: 'string' }, 295 | }, 296 | additionalProperties: true, 297 | }, 298 | }, 299 | }) 300 | 301 | expect(method.inputSchema.required).toContain('id') 302 | expect(method.inputSchema.required).toContain('avatar') 303 | expect(method.inputSchema.required).toContain('details') 304 | expect(method.inputSchema.required).not.toContain('gallery') 305 | expect(method.inputSchema.required).not.toContain('preferences') 306 | }) 307 | 308 | it('handles optional file uploads in multipart forms', () => { 309 | const spec: OpenAPIV3.Document = { 310 | openapi: '3.0.0', 311 | info: { title: 'Test API', version: '1.0.0' }, 312 | paths: { 313 | '/pets/{id}/metadata': { 314 | post: { 315 | operationId: 'updatePetMetadata', 316 | summary: 'Update pet metadata with optional attachments', 317 | parameters: [ 318 | { 319 | name: 'id', 320 | in: 'path', 321 | required: true, 322 | schema: { type: 'integer' }, 323 | }, 324 | ], 325 | requestBody: { 326 | required: true, 327 | content: { 328 | 'multipart/form-data': { 329 | schema: { 330 | type: 'object', 331 | required: ['metadata'], 332 | properties: { 333 | metadata: { 334 | type: 'object', 335 | required: ['name'], 336 | properties: { 337 | name: { type: 'string' }, 338 | description: { type: 'string' }, 339 | }, 340 | }, 341 | certificate: { 342 | type: 'string', 343 | format: 'binary', 344 | description: 'Optional pet certificate', 345 | }, 346 | vaccinations: { 347 | type: 'array', 348 | items: { 349 | type: 'string', 350 | format: 'binary', 351 | }, 352 | description: 'Optional vaccination records', 353 | }, 354 | }, 355 | }, 356 | }, 357 | }, 358 | }, 359 | responses: { 360 | '200': { 361 | description: 'Metadata updated successfully', 362 | }, 363 | }, 364 | }, 365 | }, 366 | }, 367 | } 368 | 369 | const converter = new OpenAPIToMCPConverter(spec) 370 | const { tools } = converter.convertToMCPTools() 371 | const [tool] = Object.values(tools) 372 | const [method] = tool.methods 373 | 374 | expect(method.name).toBe('updatePetMetadata') 375 | expect(method.inputSchema.required).toContain('id') 376 | expect(method.inputSchema.required).toContain('metadata') 377 | expect(method.inputSchema.required).not.toContain('certificate') 378 | expect(method.inputSchema.required).not.toContain('vaccinations') 379 | 380 | expect(method.inputSchema.properties).toEqual({ 381 | id: { 382 | type: 'integer', 383 | }, 384 | metadata: { 385 | type: 'object', 386 | required: ['name'], 387 | properties: { 388 | name: { type: 'string' }, 389 | description: { type: 'string' }, 390 | }, 391 | additionalProperties: true, 392 | }, 393 | certificate: { 394 | type: 'string', 395 | format: 'uri-reference', 396 | description: expect.stringContaining('Optional pet certificate (absolute paths to local files)'), 397 | }, 398 | vaccinations: { 399 | type: 'array', 400 | items: { 401 | type: 'string', 402 | format: 'uri-reference', 403 | description: 'absolute paths to local files', 404 | }, 405 | description: expect.stringContaining('Optional vaccination records'), 406 | }, 407 | }) 408 | }) 409 | 410 | it('handles nested objects with file arrays in multipart forms', () => { 411 | const spec: OpenAPIV3.Document = { 412 | openapi: '3.0.0', 413 | info: { title: 'Test API', version: '1.0.0' }, 414 | paths: { 415 | '/pets/{id}/medical-records': { 416 | post: { 417 | operationId: 'addMedicalRecord', 418 | summary: 'Add medical record with attachments', 419 | parameters: [ 420 | { 421 | name: 'id', 422 | in: 'path', 423 | required: true, 424 | schema: { type: 'integer' }, 425 | }, 426 | ], 427 | requestBody: { 428 | required: true, 429 | content: { 430 | 'multipart/form-data': { 431 | schema: { 432 | type: 'object', 433 | required: ['record'], 434 | properties: { 435 | record: { 436 | type: 'object', 437 | required: ['date', 'type'], 438 | properties: { 439 | date: { type: 'string', format: 'date' }, 440 | type: { type: 'string' }, 441 | notes: { type: 'string' }, 442 | attachments: { 443 | type: 'array', 444 | items: { 445 | type: 'object', 446 | required: ['file', 'type'], 447 | properties: { 448 | file: { 449 | type: 'string', 450 | format: 'binary', 451 | }, 452 | type: { 453 | type: 'string', 454 | enum: ['xray', 'lab', 'prescription'], 455 | }, 456 | description: { type: 'string' }, 457 | }, 458 | }, 459 | }, 460 | }, 461 | }, 462 | }, 463 | }, 464 | }, 465 | }, 466 | }, 467 | responses: { 468 | '201': { 469 | description: 'Medical record added successfully', 470 | }, 471 | }, 472 | }, 473 | }, 474 | }, 475 | } 476 | 477 | const converter = new OpenAPIToMCPConverter(spec) 478 | const { tools } = converter.convertToMCPTools() 479 | const [tool] = Object.values(tools) 480 | const [method] = tool.methods 481 | 482 | expect(method.name).toBe('addMedicalRecord') 483 | expect(method.inputSchema.required).toContain('id') 484 | expect(method.inputSchema.required).toContain('record') 485 | 486 | // Verify nested structure is preserved 487 | const recordSchema = method.inputSchema.properties!.record as any 488 | expect(recordSchema.type).toBe('object') 489 | expect(recordSchema.required).toContain('date') 490 | expect(recordSchema.required).toContain('type') 491 | 492 | // Verify nested file array structure 493 | const attachmentsSchema = recordSchema.properties.attachments 494 | expect(attachmentsSchema.type).toBe('array') 495 | expect(attachmentsSchema.items.type).toBe('object') 496 | expect(attachmentsSchema.items.properties.file.format).toBe('uri-reference') 497 | expect(attachmentsSchema.items.properties.file.description).toBe('absolute paths to local files') 498 | expect(attachmentsSchema.items.required).toContain('file') 499 | expect(attachmentsSchema.items.required).toContain('type') 500 | }) 501 | 502 | it('handles oneOf/anyOf schemas with file uploads', () => { 503 | const spec: OpenAPIV3.Document = { 504 | openapi: '3.0.0', 505 | info: { title: 'Test API', version: '1.0.0' }, 506 | paths: { 507 | '/pets/{id}/content': { 508 | post: { 509 | operationId: 'addPetContent', 510 | summary: 'Add pet content (photo or document)', 511 | parameters: [ 512 | { 513 | name: 'id', 514 | in: 'path', 515 | required: true, 516 | schema: { type: 'integer' }, 517 | }, 518 | ], 519 | requestBody: { 520 | required: true, 521 | content: { 522 | 'multipart/form-data': { 523 | schema: { 524 | type: 'object', 525 | required: ['content'], 526 | properties: { 527 | content: { 528 | oneOf: [ 529 | { 530 | type: 'object', 531 | required: ['photo', 'isProfile'], 532 | properties: { 533 | photo: { 534 | type: 'string', 535 | format: 'binary', 536 | }, 537 | isProfile: { 538 | type: 'boolean', 539 | }, 540 | }, 541 | }, 542 | { 543 | type: 'object', 544 | required: ['document', 'category'], 545 | properties: { 546 | document: { 547 | type: 'string', 548 | format: 'binary', 549 | }, 550 | category: { 551 | type: 'string', 552 | enum: ['medical', 'training', 'adoption'], 553 | }, 554 | }, 555 | }, 556 | ], 557 | }, 558 | }, 559 | }, 560 | }, 561 | }, 562 | }, 563 | responses: { 564 | '201': { 565 | description: 'Content added successfully', 566 | }, 567 | }, 568 | }, 569 | }, 570 | }, 571 | } 572 | 573 | const converter = new OpenAPIToMCPConverter(spec) 574 | const { tools } = converter.convertToMCPTools() 575 | const [tool] = Object.values(tools) 576 | const [method] = tool.methods 577 | 578 | expect(method.name).toBe('addPetContent') 579 | expect(method.inputSchema.required).toContain('id') 580 | expect(method.inputSchema.required).toContain('content') 581 | 582 | // Verify oneOf structure is preserved 583 | const contentSchema = method.inputSchema.properties!.content as any 584 | expect(contentSchema.oneOf).toHaveLength(2) 585 | 586 | // Check photo option 587 | const photoOption = contentSchema.oneOf[0] 588 | expect(photoOption.type).toBe('object') 589 | expect(photoOption.properties.photo.format).toBe('uri-reference') 590 | expect(photoOption.properties.photo.description).toBe('absolute paths to local files') 591 | expect(photoOption.required).toContain('photo') 592 | expect(photoOption.required).toContain('isProfile') 593 | 594 | // Check document option 595 | const documentOption = contentSchema.oneOf[1] 596 | expect(documentOption.type).toBe('object') 597 | expect(documentOption.properties.document.format).toBe('uri-reference') 598 | expect(documentOption.properties.document.description).toBe('absolute paths to local files') 599 | expect(documentOption.required).toContain('document') 600 | expect(documentOption.required).toContain('category') 601 | }) 602 | }) 603 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/openapi/__tests__/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIToMCPConverter } from '../parser' 2 | import { OpenAPIV3 } from 'openapi-types' 3 | import { describe, expect, it } from 'vitest' 4 | import { JSONSchema7 as IJsonSchema } from 'json-schema' 5 | 6 | interface ToolMethod { 7 | name: string 8 | description: string 9 | inputSchema: any 10 | returnSchema?: any 11 | } 12 | 13 | interface Tool { 14 | methods: ToolMethod[] 15 | } 16 | 17 | interface Tools { 18 | [key: string]: Tool 19 | } 20 | 21 | // Helper function to verify tool method structure without checking the exact Zod schema 22 | function verifyToolMethod(actual: ToolMethod, expected: any, toolName: string) { 23 | expect(actual.name).toBe(expected.name) 24 | expect(actual.description).toBe(expected.description) 25 | expect(actual.inputSchema, `inputSchema ${actual.name} ${toolName}`).toEqual(expected.inputSchema) 26 | if (expected.returnSchema) { 27 | expect(actual.returnSchema, `returnSchema ${actual.name} ${toolName}`).toEqual(expected.returnSchema) 28 | } 29 | } 30 | 31 | // Helper function to verify tools structure 32 | function verifyTools(actual: Tools, expected: any) { 33 | expect(Object.keys(actual)).toEqual(Object.keys(expected)) 34 | for (const [key, value] of Object.entries(actual)) { 35 | expect(value.methods.length).toBe(expected[key].methods.length) 36 | value.methods.forEach((method: ToolMethod, index: number) => { 37 | verifyToolMethod(method, expected[key].methods[index], key) 38 | }) 39 | } 40 | } 41 | 42 | // A helper function to derive a type from a possibly complex schema. 43 | // If no explicit type is found, we assume 'object' for testing purposes. 44 | function getTypeFromSchema(schema: IJsonSchema): string { 45 | if (schema.type) { 46 | return Array.isArray(schema.type) ? schema.type[0] : schema.type 47 | } else if (schema.$ref) { 48 | // If there's a $ref, we treat it as an object reference. 49 | return 'object' 50 | } else if (schema.oneOf || schema.anyOf || schema.allOf) { 51 | // Complex schema combos - assume object for these tests. 52 | return 'object' 53 | } 54 | return 'object' 55 | } 56 | 57 | // Updated helper function to get parameters from inputSchema 58 | // Now handles $ref by treating it as an object reference without expecting properties. 59 | function getParamsFromSchema(method: { inputSchema: IJsonSchema }) { 60 | return Object.entries(method.inputSchema.properties || {}).map(([name, prop]) => { 61 | if (typeof prop === 'boolean') { 62 | throw new Error(`Boolean schema not supported for parameter ${name}`) 63 | } 64 | 65 | // If there's a $ref, treat it as an object reference. 66 | const schemaType = getTypeFromSchema(prop) 67 | return { 68 | name, 69 | type: schemaType, 70 | description: prop.description, 71 | optional: !(method.inputSchema.required || []).includes(name), 72 | } 73 | }) 74 | } 75 | 76 | // Updated helper function to get return type from returnSchema 77 | // No longer requires that the schema be fully expanded. If we have a $ref, just note it as 'object'. 78 | function getReturnType(method: { returnSchema?: IJsonSchema }) { 79 | if (!method.returnSchema) return null 80 | const schema = method.returnSchema 81 | return { 82 | type: getTypeFromSchema(schema), 83 | description: schema.description, 84 | } 85 | } 86 | 87 | describe('OpenAPIToMCPConverter', () => { 88 | describe('Simple API Conversion', () => { 89 | const sampleSpec: OpenAPIV3.Document = { 90 | openapi: '3.0.0', 91 | info: { 92 | title: 'Test API', 93 | version: '1.0.0', 94 | }, 95 | paths: { 96 | '/pets/{petId}': { 97 | get: { 98 | operationId: 'getPet', 99 | summary: 'Get a pet by ID', 100 | parameters: [ 101 | { 102 | name: 'petId', 103 | in: 'path', 104 | required: true, 105 | description: 'The ID of the pet', 106 | schema: { 107 | type: 'integer', 108 | }, 109 | }, 110 | ], 111 | responses: { 112 | '200': { 113 | description: 'Pet found', 114 | content: { 115 | 'application/json': { 116 | schema: { 117 | type: 'object', 118 | properties: { 119 | id: { type: 'integer' }, 120 | name: { type: 'string' }, 121 | }, 122 | }, 123 | }, 124 | }, 125 | }, 126 | }, 127 | }, 128 | }, 129 | }, 130 | } 131 | 132 | it('converts simple OpenAPI paths to MCP tools', () => { 133 | const converter = new OpenAPIToMCPConverter(sampleSpec) 134 | const { tools, openApiLookup } = converter.convertToMCPTools() 135 | 136 | expect(tools).toHaveProperty('API') 137 | expect(tools.API.methods).toHaveLength(1) 138 | expect(Object.keys(openApiLookup)).toHaveLength(1) 139 | 140 | const getPetMethod = tools.API.methods.find((m) => m.name === 'getPet') 141 | expect(getPetMethod).toBeDefined() 142 | 143 | const params = getParamsFromSchema(getPetMethod!) 144 | expect(params).toContainEqual({ 145 | name: 'petId', 146 | type: 'integer', 147 | description: 'The ID of the pet', 148 | optional: false, 149 | }) 150 | }) 151 | 152 | it('truncates tool names exceeding 64 characters', () => { 153 | const longOperationId = 'a'.repeat(65) 154 | const specWithLongName: OpenAPIV3.Document = { 155 | openapi: '3.0.0', 156 | info: { 157 | title: 'Test API', 158 | version: '1.0.0' 159 | }, 160 | paths: { 161 | '/pets/{petId}': { 162 | get: { 163 | operationId: longOperationId, 164 | summary: 'Get a pet by ID', 165 | parameters: [ 166 | { 167 | name: 'petId', 168 | in: 'path', 169 | required: true, 170 | description: 'The ID of the pet', 171 | schema: { 172 | type: 'integer' 173 | } 174 | } 175 | ], 176 | responses: { 177 | '200': { 178 | description: 'Pet found', 179 | content: { 180 | 'application/json': { 181 | schema: { 182 | type: 'object', 183 | properties: { 184 | id: { type: 'integer' }, 185 | name: { type: 'string' } 186 | } 187 | } 188 | } 189 | } 190 | } 191 | } 192 | } 193 | } 194 | } 195 | } 196 | 197 | const converter = new OpenAPIToMCPConverter(specWithLongName) 198 | const { tools } = converter.convertToMCPTools() 199 | 200 | const longNameMethod = tools.API.methods.find(m => m.name.startsWith('a'.repeat(59))) 201 | expect(longNameMethod).toBeDefined() 202 | expect(longNameMethod!.name.length).toBeLessThanOrEqual(64) 203 | }) 204 | }) 205 | 206 | describe('Complex API Conversion', () => { 207 | const complexSpec: OpenAPIV3.Document = { 208 | openapi: '3.0.0', 209 | info: { title: 'Complex API', version: '1.0.0' }, 210 | components: { 211 | schemas: { 212 | Error: { 213 | type: 'object', 214 | required: ['code', 'message'], 215 | properties: { 216 | code: { type: 'integer' }, 217 | message: { type: 'string' }, 218 | }, 219 | }, 220 | Pet: { 221 | type: 'object', 222 | required: ['id', 'name'], 223 | properties: { 224 | id: { type: 'integer', description: 'The ID of the pet' }, 225 | name: { type: 'string', description: 'The name of the pet' }, 226 | category: { $ref: '#/components/schemas/Category', description: 'The category of the pet' }, 227 | tags: { 228 | type: 'array', 229 | description: 'The tags of the pet', 230 | items: { $ref: '#/components/schemas/Tag' }, 231 | }, 232 | status: { 233 | type: 'string', 234 | description: 'The status of the pet', 235 | enum: ['available', 'pending', 'sold'], 236 | }, 237 | }, 238 | }, 239 | Category: { 240 | type: 'object', 241 | required: ['id', 'name'], 242 | properties: { 243 | id: { type: 'integer' }, 244 | name: { type: 'string' }, 245 | subcategories: { 246 | type: 'array', 247 | items: { $ref: '#/components/schemas/Category' }, 248 | }, 249 | }, 250 | }, 251 | Tag: { 252 | type: 'object', 253 | required: ['id', 'name'], 254 | properties: { 255 | id: { type: 'integer' }, 256 | name: { type: 'string' }, 257 | }, 258 | }, 259 | }, 260 | parameters: { 261 | PetId: { 262 | name: 'petId', 263 | in: 'path', 264 | required: true, 265 | description: 'ID of pet to fetch', 266 | schema: { type: 'integer' }, 267 | }, 268 | QueryLimit: { 269 | name: 'limit', 270 | in: 'query', 271 | description: 'Maximum number of results to return', 272 | schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, 273 | }, 274 | }, 275 | responses: { 276 | NotFound: { 277 | description: 'The specified resource was not found', 278 | content: { 279 | 'application/json': { 280 | schema: { $ref: '#/components/schemas/Error' }, 281 | }, 282 | }, 283 | }, 284 | }, 285 | }, 286 | paths: { 287 | '/pets': { 288 | get: { 289 | operationId: 'listPets', 290 | summary: 'List all pets', 291 | parameters: [{ $ref: '#/components/parameters/QueryLimit' }], 292 | responses: { 293 | '200': { 294 | description: 'A list of pets', 295 | content: { 296 | 'application/json': { 297 | schema: { 298 | type: 'array', 299 | items: { $ref: '#/components/schemas/Pet' }, 300 | }, 301 | }, 302 | }, 303 | }, 304 | }, 305 | }, 306 | post: { 307 | operationId: 'createPet', 308 | summary: 'Create a pet', 309 | requestBody: { 310 | required: true, 311 | content: { 312 | 'application/json': { 313 | schema: { $ref: '#/components/schemas/Pet' }, 314 | }, 315 | }, 316 | }, 317 | responses: { 318 | '201': { 319 | description: 'Pet created', 320 | content: { 321 | 'application/json': { 322 | schema: { $ref: '#/components/schemas/Pet' }, 323 | }, 324 | }, 325 | }, 326 | }, 327 | }, 328 | }, 329 | '/pets/{petId}': { 330 | get: { 331 | operationId: 'getPet', 332 | summary: 'Get a pet by ID', 333 | parameters: [{ $ref: '#/components/parameters/PetId' }], 334 | responses: { 335 | '200': { 336 | description: 'Pet found', 337 | content: { 338 | 'application/json': { 339 | schema: { $ref: '#/components/schemas/Pet' }, 340 | }, 341 | }, 342 | }, 343 | '404': { 344 | $ref: '#/components/responses/NotFound', 345 | }, 346 | }, 347 | }, 348 | put: { 349 | operationId: 'updatePet', 350 | summary: 'Update a pet', 351 | parameters: [{ $ref: '#/components/parameters/PetId' }], 352 | requestBody: { 353 | required: true, 354 | content: { 355 | 'application/json': { 356 | schema: { $ref: '#/components/schemas/Pet' }, 357 | }, 358 | }, 359 | }, 360 | responses: { 361 | '200': { 362 | description: 'Pet updated', 363 | content: { 364 | 'application/json': { 365 | schema: { $ref: '#/components/schemas/Pet' }, 366 | }, 367 | }, 368 | }, 369 | '404': { 370 | $ref: '#/components/responses/NotFound', 371 | }, 372 | }, 373 | }, 374 | }, 375 | }, 376 | } 377 | 378 | it('converts operations with referenced parameters', () => { 379 | const converter = new OpenAPIToMCPConverter(complexSpec) 380 | const { tools } = converter.convertToMCPTools() 381 | 382 | const getPetMethod = tools.API.methods.find((m) => m.name === 'getPet') 383 | expect(getPetMethod).toBeDefined() 384 | const params = getParamsFromSchema(getPetMethod!) 385 | expect(params).toContainEqual({ 386 | name: 'petId', 387 | type: 'integer', 388 | description: 'ID of pet to fetch', 389 | optional: false, 390 | }) 391 | }) 392 | 393 | it('converts operations with query parameters', () => { 394 | const converter = new OpenAPIToMCPConverter(complexSpec) 395 | const { tools } = converter.convertToMCPTools() 396 | 397 | const listPetsMethod = tools.API.methods.find((m) => m.name === 'listPets') 398 | expect(listPetsMethod).toBeDefined() 399 | 400 | const params = getParamsFromSchema(listPetsMethod!) 401 | expect(params).toContainEqual({ 402 | name: 'limit', 403 | type: 'integer', 404 | description: 'Maximum number of results to return', 405 | optional: true, 406 | }) 407 | }) 408 | 409 | it('converts operations with array responses', () => { 410 | const converter = new OpenAPIToMCPConverter(complexSpec) 411 | const { tools } = converter.convertToMCPTools() 412 | 413 | const listPetsMethod = tools.API.methods.find((m) => m.name === 'listPets') 414 | expect(listPetsMethod).toBeDefined() 415 | 416 | const returnType = getReturnType(listPetsMethod!) 417 | // Now we only check type since description might not be carried through 418 | // if we are not expanding schemas. 419 | expect(returnType).toMatchObject({ 420 | type: 'array', 421 | }) 422 | }) 423 | 424 | it('converts operations with request bodies using $ref', () => { 425 | const converter = new OpenAPIToMCPConverter(complexSpec) 426 | const { tools } = converter.convertToMCPTools() 427 | 428 | const createPetMethod = tools.API.methods.find((m) => m.name === 'createPet') 429 | expect(createPetMethod).toBeDefined() 430 | 431 | const params = getParamsFromSchema(createPetMethod!) 432 | // Now that we are preserving $ref, the request body won't be expanded into multiple parameters. 433 | // Instead, we'll have a single "body" parameter referencing Pet. 434 | expect(params).toEqual( 435 | expect.arrayContaining([ 436 | expect.objectContaining({ 437 | name: 'body', 438 | type: 'object', // Because it's a $ref 439 | optional: false, 440 | }), 441 | ]), 442 | ) 443 | }) 444 | 445 | it('converts operations with referenced error responses', () => { 446 | const converter = new OpenAPIToMCPConverter(complexSpec) 447 | const { tools } = converter.convertToMCPTools() 448 | 449 | const getPetMethod = tools.API.methods.find((m) => m.name === 'getPet') 450 | expect(getPetMethod).toBeDefined() 451 | 452 | // We just check that the description includes the error references now. 453 | expect(getPetMethod?.description).toContain('404: The specified resource was not found') 454 | }) 455 | 456 | it('handles recursive schema references without expanding them', () => { 457 | const converter = new OpenAPIToMCPConverter(complexSpec) 458 | const { tools } = converter.convertToMCPTools() 459 | 460 | const createPetMethod = tools.API.methods.find((m) => m.name === 'createPet') 461 | expect(createPetMethod).toBeDefined() 462 | 463 | const params = getParamsFromSchema(createPetMethod!) 464 | // Since "category" would be inside Pet, and we're not expanding, 465 | // we won't see 'category' directly. We only have 'body' as a reference. 466 | // Thus, the test no longer checks for a direct 'category' param. 467 | expect(params.find((p) => p.name === 'body')).toBeDefined() 468 | }) 469 | 470 | it('converts all operations correctly respecting $ref usage', () => { 471 | const converter = new OpenAPIToMCPConverter(complexSpec) 472 | const { tools } = converter.convertToMCPTools() 473 | 474 | expect(tools.API.methods).toHaveLength(4) 475 | 476 | const methodNames = tools.API.methods.map((m) => m.name) 477 | expect(methodNames).toEqual(expect.arrayContaining(['listPets', 'createPet', 'getPet', 'updatePet'])) 478 | 479 | tools.API.methods.forEach((method) => { 480 | expect(method).toHaveProperty('name') 481 | expect(method).toHaveProperty('description') 482 | expect(method).toHaveProperty('inputSchema') 483 | expect(method).toHaveProperty('returnSchema') 484 | 485 | // For 'get' operations, we just check the return type is recognized correctly. 486 | if (method.name.startsWith('get')) { 487 | const returnType = getReturnType(method) 488 | // With $ref usage, we can't guarantee description or direct expansion. 489 | expect(returnType?.type).toBe('object') 490 | } 491 | }) 492 | }) 493 | }) 494 | 495 | describe('Complex Schema Conversion', () => { 496 | // A similar approach for the nested spec 497 | // Just as in the previous tests, we no longer test for direct property expansion. 498 | // We only confirm that parameters and return types are recognized and that references are preserved. 499 | 500 | const nestedSpec: OpenAPIV3.Document = { 501 | openapi: '3.0.0', 502 | info: { title: 'Nested API', version: '1.0.0' }, 503 | components: { 504 | schemas: { 505 | Organization: { 506 | type: 'object', 507 | required: ['id', 'name'], 508 | properties: { 509 | id: { type: 'integer' }, 510 | name: { type: 'string' }, 511 | departments: { 512 | type: 'array', 513 | items: { $ref: '#/components/schemas/Department' }, 514 | }, 515 | metadata: { $ref: '#/components/schemas/Metadata' }, 516 | }, 517 | }, 518 | Department: { 519 | type: 'object', 520 | required: ['id', 'name'], 521 | properties: { 522 | id: { type: 'integer' }, 523 | name: { type: 'string' }, 524 | employees: { 525 | type: 'array', 526 | items: { $ref: '#/components/schemas/Employee' }, 527 | }, 528 | subDepartments: { 529 | type: 'array', 530 | items: { $ref: '#/components/schemas/Department' }, 531 | }, 532 | metadata: { $ref: '#/components/schemas/Metadata' }, 533 | }, 534 | }, 535 | Employee: { 536 | type: 'object', 537 | required: ['id', 'name'], 538 | properties: { 539 | id: { type: 'integer' }, 540 | name: { type: 'string' }, 541 | role: { $ref: '#/components/schemas/Role' }, 542 | skills: { 543 | type: 'array', 544 | items: { $ref: '#/components/schemas/Skill' }, 545 | }, 546 | metadata: { $ref: '#/components/schemas/Metadata' }, 547 | }, 548 | }, 549 | Role: { 550 | type: 'object', 551 | required: ['id', 'name'], 552 | properties: { 553 | id: { type: 'integer' }, 554 | name: { type: 'string' }, 555 | permissions: { 556 | type: 'array', 557 | items: { $ref: '#/components/schemas/Permission' }, 558 | }, 559 | }, 560 | }, 561 | Permission: { 562 | type: 'object', 563 | required: ['id', 'name'], 564 | properties: { 565 | id: { type: 'integer' }, 566 | name: { type: 'string' }, 567 | scope: { type: 'string' }, 568 | }, 569 | }, 570 | Skill: { 571 | type: 'object', 572 | required: ['id', 'name'], 573 | properties: { 574 | id: { type: 'integer' }, 575 | name: { type: 'string' }, 576 | level: { 577 | type: 'string', 578 | enum: ['beginner', 'intermediate', 'expert'], 579 | }, 580 | }, 581 | }, 582 | Metadata: { 583 | type: 'object', 584 | properties: { 585 | createdAt: { type: 'string', format: 'date-time' }, 586 | updatedAt: { type: 'string', format: 'date-time' }, 587 | tags: { 588 | type: 'array', 589 | items: { type: 'string' }, 590 | }, 591 | customFields: { 592 | type: 'object', 593 | additionalProperties: true, 594 | }, 595 | }, 596 | }, 597 | }, 598 | parameters: { 599 | OrgId: { 600 | name: 'orgId', 601 | in: 'path', 602 | required: true, 603 | description: 'Organization ID', 604 | schema: { type: 'integer' }, 605 | }, 606 | DeptId: { 607 | name: 'deptId', 608 | in: 'path', 609 | required: true, 610 | description: 'Department ID', 611 | schema: { type: 'integer' }, 612 | }, 613 | IncludeMetadata: { 614 | name: 'includeMetadata', 615 | in: 'query', 616 | description: 'Include metadata in response', 617 | schema: { type: 'boolean', default: false }, 618 | }, 619 | Depth: { 620 | name: 'depth', 621 | in: 'query', 622 | description: 'Depth of nested objects to return', 623 | schema: { type: 'integer', minimum: 1, maximum: 5, default: 1 }, 624 | }, 625 | }, 626 | }, 627 | paths: { 628 | '/organizations/{orgId}': { 629 | get: { 630 | operationId: 'getOrganization', 631 | summary: 'Get organization details', 632 | parameters: [ 633 | { $ref: '#/components/parameters/OrgId' }, 634 | { $ref: '#/components/parameters/IncludeMetadata' }, 635 | { $ref: '#/components/parameters/Depth' }, 636 | ], 637 | responses: { 638 | '200': { 639 | description: 'Organization details', 640 | content: { 641 | 'application/json': { 642 | schema: { $ref: '#/components/schemas/Organization' }, 643 | }, 644 | }, 645 | }, 646 | }, 647 | }, 648 | }, 649 | '/organizations/{orgId}/departments/{deptId}': { 650 | get: { 651 | operationId: 'getDepartment', 652 | summary: 'Get department details', 653 | parameters: [ 654 | { $ref: '#/components/parameters/OrgId' }, 655 | { $ref: '#/components/parameters/DeptId' }, 656 | { $ref: '#/components/parameters/IncludeMetadata' }, 657 | { $ref: '#/components/parameters/Depth' }, 658 | ], 659 | responses: { 660 | '200': { 661 | description: 'Department details', 662 | content: { 663 | 'application/json': { 664 | schema: { $ref: '#/components/schemas/Department' }, 665 | }, 666 | }, 667 | }, 668 | }, 669 | }, 670 | put: { 671 | operationId: 'updateDepartment', 672 | summary: 'Update department details', 673 | parameters: [{ $ref: '#/components/parameters/OrgId' }, { $ref: '#/components/parameters/DeptId' }], 674 | requestBody: { 675 | required: true, 676 | content: { 677 | 'application/json': { 678 | schema: { $ref: '#/components/schemas/Department' }, 679 | }, 680 | }, 681 | }, 682 | responses: { 683 | '200': { 684 | description: 'Department updated', 685 | content: { 686 | 'application/json': { 687 | schema: { $ref: '#/components/schemas/Department' }, 688 | }, 689 | }, 690 | }, 691 | }, 692 | }, 693 | }, 694 | }, 695 | } 696 | 697 | it('handles deeply nested object references', () => { 698 | const converter = new OpenAPIToMCPConverter(nestedSpec) 699 | const { tools } = converter.convertToMCPTools() 700 | 701 | const getOrgMethod = tools.API.methods.find((m) => m.name === 'getOrganization') 702 | expect(getOrgMethod).toBeDefined() 703 | 704 | const params = getParamsFromSchema(getOrgMethod!) 705 | expect(params).toEqual( 706 | expect.arrayContaining([ 707 | expect.objectContaining({ 708 | name: 'orgId', 709 | type: 'integer', 710 | description: 'Organization ID', 711 | optional: false, 712 | }), 713 | expect.objectContaining({ 714 | name: 'includeMetadata', 715 | type: 'boolean', 716 | description: 'Include metadata in response', 717 | optional: true, 718 | }), 719 | expect.objectContaining({ 720 | name: 'depth', 721 | type: 'integer', 722 | description: 'Depth of nested objects to return', 723 | optional: true, 724 | }), 725 | ]), 726 | ) 727 | }) 728 | 729 | it('handles recursive array references without requiring expansion', () => { 730 | const converter = new OpenAPIToMCPConverter(nestedSpec) 731 | const { tools } = converter.convertToMCPTools() 732 | 733 | const updateDeptMethod = tools.API.methods.find((m) => m.name === 'updateDepartment') 734 | expect(updateDeptMethod).toBeDefined() 735 | 736 | const params = getParamsFromSchema(updateDeptMethod!) 737 | // With $ref usage, we have a body parameter referencing Department. 738 | // The subDepartments array is inside Department, so we won't see it expanded here. 739 | // Instead, we just confirm 'body' is present. 740 | const bodyParam = params.find((p) => p.name === 'body') 741 | expect(bodyParam).toBeDefined() 742 | expect(bodyParam?.type).toBe('object') 743 | }) 744 | 745 | it('handles complex nested object hierarchies without expansion', () => { 746 | const converter = new OpenAPIToMCPConverter(nestedSpec) 747 | const { tools } = converter.convertToMCPTools() 748 | 749 | const getDeptMethod = tools.API.methods.find((m) => m.name === 'getDepartment') 750 | expect(getDeptMethod).toBeDefined() 751 | 752 | const params = getParamsFromSchema(getDeptMethod!) 753 | // Just checking top-level params: 754 | expect(params).toEqual( 755 | expect.arrayContaining([ 756 | expect.objectContaining({ 757 | name: 'orgId', 758 | type: 'integer', 759 | optional: false, 760 | }), 761 | expect.objectContaining({ 762 | name: 'deptId', 763 | type: 'integer', 764 | optional: false, 765 | }), 766 | expect.objectContaining({ 767 | name: 'includeMetadata', 768 | type: 'boolean', 769 | optional: true, 770 | }), 771 | expect.objectContaining({ 772 | name: 'depth', 773 | type: 'integer', 774 | optional: true, 775 | }), 776 | ]), 777 | ) 778 | }) 779 | 780 | it('handles schema with mixed primitive and reference types without expansion', () => { 781 | const converter = new OpenAPIToMCPConverter(nestedSpec) 782 | const { tools } = converter.convertToMCPTools() 783 | 784 | const updateDeptMethod = tools.API.methods.find((m) => m.name === 'updateDepartment') 785 | expect(updateDeptMethod).toBeDefined() 786 | 787 | const params = getParamsFromSchema(updateDeptMethod!) 788 | // Since we are not expanding, we won't see metadata fields directly. 789 | // We just confirm 'body' referencing Department is there. 790 | expect(params.find((p) => p.name === 'body')).toBeDefined() 791 | }) 792 | 793 | it('converts all operations with complex schemas correctly respecting $ref', () => { 794 | const converter = new OpenAPIToMCPConverter(nestedSpec) 795 | const { tools } = converter.convertToMCPTools() 796 | 797 | expect(tools.API.methods).toHaveLength(3) 798 | 799 | const methodNames = tools.API.methods.map((m) => m.name) 800 | expect(methodNames).toEqual(expect.arrayContaining(['getOrganization', 'getDepartment', 'updateDepartment'])) 801 | 802 | tools.API.methods.forEach((method) => { 803 | expect(method).toHaveProperty('name') 804 | expect(method).toHaveProperty('description') 805 | expect(method).toHaveProperty('inputSchema') 806 | expect(method).toHaveProperty('returnSchema') 807 | 808 | // If it's a GET operation, check that return type is recognized. 809 | if (method.name.startsWith('get')) { 810 | const returnType = getReturnType(method) 811 | // Without expansion, just check type is recognized as object. 812 | expect(returnType).toMatchObject({ 813 | type: 'object', 814 | }) 815 | } 816 | }) 817 | }) 818 | }) 819 | 820 | it('preserves description on $ref nodes', () => { 821 | const spec: OpenAPIV3.Document = { 822 | openapi: '3.0.0', 823 | info: { title: 'Test API', version: '1.0.0' }, 824 | paths: {}, 825 | components: { 826 | schemas: { 827 | TestSchema: { 828 | type: 'object', 829 | properties: { 830 | name: { type: 'string' }, 831 | }, 832 | }, 833 | }, 834 | }, 835 | } 836 | 837 | const converter = new OpenAPIToMCPConverter(spec) 838 | const result = converter.convertOpenApiSchemaToJsonSchema( 839 | { 840 | $ref: '#/components/schemas/TestSchema', 841 | description: 'A schema description', 842 | }, 843 | new Set(), 844 | ) 845 | 846 | expect(result).toEqual({ 847 | $ref: '#/$defs/TestSchema', 848 | description: 'A schema description', 849 | }) 850 | }) 851 | }) 852 | 853 | // Additional complex test scenarios as a table test 854 | describe('OpenAPIToMCPConverter - Additional Complex Tests', () => { 855 | interface TestCase { 856 | name: string 857 | input: OpenAPIV3.Document 858 | expected: { 859 | tools: Record< 860 | string, 861 | { 862 | methods: Array<{ 863 | name: string 864 | description: string 865 | inputSchema: IJsonSchema & { type: 'object' } 866 | returnSchema?: IJsonSchema 867 | }> 868 | } 869 | > 870 | openApiLookup: Record 871 | } 872 | } 873 | 874 | const cases: TestCase[] = [ 875 | { 876 | name: 'Cyclic References with Full Descriptions', 877 | input: { 878 | openapi: '3.0.0', 879 | info: { 880 | title: 'Cyclic Test API', 881 | version: '1.0.0', 882 | }, 883 | paths: { 884 | '/ab': { 885 | get: { 886 | operationId: 'getAB', 887 | summary: 'Get an A-B object', 888 | responses: { 889 | '200': { 890 | description: 'Returns an A object', 891 | content: { 892 | 'application/json': { 893 | schema: { $ref: '#/components/schemas/A' }, 894 | }, 895 | }, 896 | }, 897 | }, 898 | }, 899 | post: { 900 | operationId: 'createAB', 901 | summary: 'Create an A-B object', 902 | requestBody: { 903 | required: true, 904 | content: { 905 | 'application/json': { 906 | schema: { 907 | $ref: '#/components/schemas/A', 908 | description: 'A schema description', 909 | }, 910 | }, 911 | }, 912 | }, 913 | responses: { 914 | '201': { 915 | description: 'Created A object', 916 | content: { 917 | 'application/json': { 918 | schema: { $ref: '#/components/schemas/A' }, 919 | }, 920 | }, 921 | }, 922 | }, 923 | }, 924 | }, 925 | }, 926 | components: { 927 | schemas: { 928 | A: { 929 | type: 'object', 930 | description: 'A schema description', 931 | required: ['name', 'b'], 932 | properties: { 933 | name: { 934 | type: 'string', 935 | description: 'Name of A', 936 | }, 937 | b: { 938 | $ref: '#/components/schemas/B', 939 | description: 'B property in A', 940 | }, 941 | }, 942 | }, 943 | B: { 944 | type: 'object', 945 | description: 'B schema description', 946 | required: ['title', 'a'], 947 | properties: { 948 | title: { 949 | type: 'string', 950 | description: 'Title of B', 951 | }, 952 | a: { 953 | $ref: '#/components/schemas/A', 954 | description: 'A property in B', 955 | }, 956 | }, 957 | }, 958 | }, 959 | }, 960 | } as OpenAPIV3.Document, 961 | expected: { 962 | tools: { 963 | API: { 964 | methods: [ 965 | { 966 | name: 'getAB', 967 | description: 'Get an A-B object', 968 | // Error responses might not be listed here since none are defined. 969 | // Just end the description with no Error Responses section. 970 | inputSchema: { 971 | type: 'object', 972 | properties: {}, 973 | required: [], 974 | $defs: { 975 | A: { 976 | type: 'object', 977 | description: 'A schema description', 978 | additionalProperties: true, 979 | properties: { 980 | name: { 981 | type: 'string', 982 | description: 'Name of A', 983 | }, 984 | b: { 985 | description: 'B property in A', 986 | $ref: '#/$defs/B', 987 | }, 988 | }, 989 | required: ['name', 'b'], 990 | }, 991 | B: { 992 | type: 'object', 993 | description: 'B schema description', 994 | additionalProperties: true, 995 | properties: { 996 | title: { 997 | type: 'string', 998 | description: 'Title of B', 999 | }, 1000 | a: { 1001 | description: 'A property in B', 1002 | $ref: '#/$defs/A', 1003 | }, 1004 | }, 1005 | required: ['title', 'a'], 1006 | }, 1007 | }, 1008 | }, 1009 | returnSchema: { 1010 | $ref: '#/$defs/A', 1011 | description: 'Returns an A object', 1012 | $defs: { 1013 | A: { 1014 | type: 'object', 1015 | description: 'A schema description', 1016 | additionalProperties: true, 1017 | properties: { 1018 | name: { 1019 | type: 'string', 1020 | description: 'Name of A', 1021 | }, 1022 | b: { 1023 | description: 'B property in A', 1024 | $ref: '#/$defs/B', 1025 | }, 1026 | }, 1027 | required: ['name', 'b'], 1028 | }, 1029 | B: { 1030 | type: 'object', 1031 | description: 'B schema description', 1032 | additionalProperties: true, 1033 | properties: { 1034 | title: { 1035 | type: 'string', 1036 | description: 'Title of B', 1037 | }, 1038 | a: { 1039 | description: 'A property in B', 1040 | $ref: '#/$defs/A', 1041 | }, 1042 | }, 1043 | required: ['title', 'a'], 1044 | }, 1045 | }, 1046 | }, 1047 | }, 1048 | { 1049 | name: 'createAB', 1050 | description: 'Create an A-B object', 1051 | inputSchema: { 1052 | type: 'object', 1053 | properties: { 1054 | // The requestBody references A. We keep it as a single body field with a $ref. 1055 | body: { 1056 | $ref: '#/$defs/A', 1057 | description: 'A schema description', 1058 | }, 1059 | }, 1060 | required: ['body'], 1061 | 1062 | $defs: { 1063 | A: { 1064 | type: 'object', 1065 | description: 'A schema description', 1066 | additionalProperties: true, 1067 | properties: { 1068 | name: { 1069 | type: 'string', 1070 | description: 'Name of A', 1071 | }, 1072 | b: { 1073 | description: 'B property in A', 1074 | $ref: '#/$defs/B', 1075 | }, 1076 | }, 1077 | required: ['name', 'b'], 1078 | }, 1079 | B: { 1080 | type: 'object', 1081 | description: 'B schema description', 1082 | additionalProperties: true, 1083 | properties: { 1084 | title: { 1085 | type: 'string', 1086 | description: 'Title of B', 1087 | }, 1088 | a: { 1089 | description: 'A property in B', 1090 | $ref: '#/$defs/A', 1091 | }, 1092 | }, 1093 | required: ['title', 'a'], 1094 | }, 1095 | }, 1096 | }, 1097 | returnSchema: { 1098 | $ref: '#/$defs/A', 1099 | description: 'Created A object', 1100 | 1101 | $defs: { 1102 | A: { 1103 | type: 'object', 1104 | description: 'A schema description', 1105 | additionalProperties: true, 1106 | properties: { 1107 | name: { 1108 | type: 'string', 1109 | description: 'Name of A', 1110 | }, 1111 | b: { 1112 | description: 'B property in A', 1113 | $ref: '#/$defs/B', 1114 | }, 1115 | }, 1116 | required: ['name', 'b'], 1117 | }, 1118 | B: { 1119 | type: 'object', 1120 | description: 'B schema description', 1121 | additionalProperties: true, 1122 | properties: { 1123 | title: { 1124 | type: 'string', 1125 | description: 'Title of B', 1126 | }, 1127 | a: { 1128 | description: 'A property in B', 1129 | $ref: '#/$defs/A', 1130 | }, 1131 | }, 1132 | required: ['title', 'a'], 1133 | }, 1134 | }, 1135 | }, 1136 | }, 1137 | ], 1138 | }, 1139 | }, 1140 | openApiLookup: { 1141 | 'API-getAB': { 1142 | operationId: 'getAB', 1143 | summary: 'Get an A-B object', 1144 | responses: { 1145 | '200': { 1146 | description: 'Returns an A object', 1147 | content: { 1148 | 'application/json': { 1149 | schema: { $ref: '#/components/schemas/A' }, 1150 | }, 1151 | }, 1152 | }, 1153 | }, 1154 | method: 'get', 1155 | path: '/ab', 1156 | }, 1157 | 'API-createAB': { 1158 | operationId: 'createAB', 1159 | summary: 'Create an A-B object', 1160 | requestBody: { 1161 | required: true, 1162 | content: { 1163 | 'application/json': { 1164 | schema: { 1165 | $ref: '#/components/schemas/A', 1166 | description: 'A schema description', 1167 | }, 1168 | }, 1169 | }, 1170 | }, 1171 | responses: { 1172 | '201': { 1173 | description: 'Created A object', 1174 | content: { 1175 | 'application/json': { 1176 | schema: { $ref: '#/components/schemas/A' }, 1177 | }, 1178 | }, 1179 | }, 1180 | }, 1181 | method: 'post', 1182 | path: '/ab', 1183 | }, 1184 | }, 1185 | }, 1186 | }, 1187 | { 1188 | name: 'allOf/oneOf References with Full Descriptions', 1189 | input: { 1190 | openapi: '3.0.0', 1191 | info: { title: 'Composed Schema API', version: '1.0.0' }, 1192 | paths: { 1193 | '/composed': { 1194 | get: { 1195 | operationId: 'getComposed', 1196 | summary: 'Get a composed resource', 1197 | responses: { 1198 | '200': { 1199 | description: 'A composed object', 1200 | content: { 1201 | 'application/json': { 1202 | schema: { $ref: '#/components/schemas/C' }, 1203 | }, 1204 | }, 1205 | }, 1206 | }, 1207 | }, 1208 | }, 1209 | }, 1210 | components: { 1211 | schemas: { 1212 | Base: { 1213 | type: 'object', 1214 | description: 'Base schema description', 1215 | properties: { 1216 | baseName: { 1217 | type: 'string', 1218 | description: 'Name in the base schema', 1219 | }, 1220 | }, 1221 | }, 1222 | D: { 1223 | type: 'object', 1224 | description: 'D schema description', 1225 | properties: { 1226 | dProp: { 1227 | type: 'integer', 1228 | description: 'D property integer', 1229 | }, 1230 | }, 1231 | }, 1232 | E: { 1233 | type: 'object', 1234 | description: 'E schema description', 1235 | properties: { 1236 | choice: { 1237 | description: 'One of these choices', 1238 | oneOf: [ 1239 | { 1240 | $ref: '#/components/schemas/F', 1241 | }, 1242 | { 1243 | $ref: '#/components/schemas/G', 1244 | }, 1245 | ], 1246 | }, 1247 | }, 1248 | }, 1249 | F: { 1250 | type: 'object', 1251 | description: 'F schema description', 1252 | properties: { 1253 | fVal: { 1254 | type: 'boolean', 1255 | description: 'Boolean in F', 1256 | }, 1257 | }, 1258 | }, 1259 | G: { 1260 | type: 'object', 1261 | description: 'G schema description', 1262 | properties: { 1263 | gVal: { 1264 | type: 'string', 1265 | description: 'String in G', 1266 | }, 1267 | }, 1268 | }, 1269 | C: { 1270 | description: 'C schema description', 1271 | allOf: [{ $ref: '#/components/schemas/Base' }, { $ref: '#/components/schemas/D' }, { $ref: '#/components/schemas/E' }], 1272 | }, 1273 | }, 1274 | }, 1275 | } as OpenAPIV3.Document, 1276 | expected: { 1277 | tools: { 1278 | API: { 1279 | methods: [ 1280 | { 1281 | name: 'getComposed', 1282 | description: 'Get a composed resource', 1283 | inputSchema: { 1284 | type: 'object', 1285 | properties: {}, 1286 | required: [], 1287 | $defs: { 1288 | Base: { 1289 | type: 'object', 1290 | description: 'Base schema description', 1291 | additionalProperties: true, 1292 | properties: { 1293 | baseName: { 1294 | type: 'string', 1295 | description: 'Name in the base schema', 1296 | }, 1297 | }, 1298 | }, 1299 | C: { 1300 | description: 'C schema description', 1301 | allOf: [{ $ref: '#/$defs/Base' }, { $ref: '#/$defs/D' }, { $ref: '#/$defs/E' }], 1302 | }, 1303 | D: { 1304 | type: 'object', 1305 | additionalProperties: true, 1306 | description: 'D schema description', 1307 | properties: { 1308 | dProp: { 1309 | type: 'integer', 1310 | description: 'D property integer', 1311 | }, 1312 | }, 1313 | }, 1314 | E: { 1315 | type: 'object', 1316 | additionalProperties: true, 1317 | description: 'E schema description', 1318 | properties: { 1319 | choice: { 1320 | description: 'One of these choices', 1321 | oneOf: [{ $ref: '#/$defs/F' }, { $ref: '#/$defs/G' }], 1322 | }, 1323 | }, 1324 | }, 1325 | F: { 1326 | type: 'object', 1327 | additionalProperties: true, 1328 | description: 'F schema description', 1329 | properties: { 1330 | fVal: { 1331 | type: 'boolean', 1332 | description: 'Boolean in F', 1333 | }, 1334 | }, 1335 | }, 1336 | G: { 1337 | type: 'object', 1338 | additionalProperties: true, 1339 | description: 'G schema description', 1340 | properties: { 1341 | gVal: { 1342 | type: 'string', 1343 | description: 'String in G', 1344 | }, 1345 | }, 1346 | }, 1347 | }, 1348 | }, 1349 | returnSchema: { 1350 | $ref: '#/$defs/C', 1351 | description: 'A composed object', 1352 | $defs: { 1353 | Base: { 1354 | type: 'object', 1355 | description: 'Base schema description', 1356 | additionalProperties: true, 1357 | properties: { 1358 | baseName: { 1359 | type: 'string', 1360 | description: 'Name in the base schema', 1361 | }, 1362 | }, 1363 | }, 1364 | C: { 1365 | description: 'C schema description', 1366 | allOf: [{ $ref: '#/$defs/Base' }, { $ref: '#/$defs/D' }, { $ref: '#/$defs/E' }], 1367 | }, 1368 | D: { 1369 | type: 'object', 1370 | additionalProperties: true, 1371 | description: 'D schema description', 1372 | properties: { 1373 | dProp: { 1374 | type: 'integer', 1375 | description: 'D property integer', 1376 | }, 1377 | }, 1378 | }, 1379 | E: { 1380 | type: 'object', 1381 | additionalProperties: true, 1382 | description: 'E schema description', 1383 | properties: { 1384 | choice: { 1385 | description: 'One of these choices', 1386 | oneOf: [{ $ref: '#/$defs/F' }, { $ref: '#/$defs/G' }], 1387 | }, 1388 | }, 1389 | }, 1390 | F: { 1391 | type: 'object', 1392 | additionalProperties: true, 1393 | description: 'F schema description', 1394 | properties: { 1395 | fVal: { 1396 | type: 'boolean', 1397 | description: 'Boolean in F', 1398 | }, 1399 | }, 1400 | }, 1401 | G: { 1402 | type: 'object', 1403 | additionalProperties: true, 1404 | description: 'G schema description', 1405 | properties: { 1406 | gVal: { 1407 | type: 'string', 1408 | description: 'String in G', 1409 | }, 1410 | }, 1411 | }, 1412 | }, 1413 | }, 1414 | }, 1415 | ], 1416 | }, 1417 | }, 1418 | openApiLookup: { 1419 | 'API-getComposed': { 1420 | operationId: 'getComposed', 1421 | summary: 'Get a composed resource', 1422 | responses: { 1423 | '200': { 1424 | description: 'A composed object', 1425 | content: { 1426 | 'application/json': { 1427 | schema: { $ref: '#/components/schemas/C' }, 1428 | }, 1429 | }, 1430 | }, 1431 | }, 1432 | method: 'get', 1433 | path: '/composed', 1434 | }, 1435 | }, 1436 | }, 1437 | }, 1438 | ] 1439 | 1440 | it.each(cases)('$name', ({ input, expected }) => { 1441 | const converter = new OpenAPIToMCPConverter(input) 1442 | const { tools, openApiLookup } = converter.convertToMCPTools() 1443 | 1444 | // Use the custom verification instead of direct equality 1445 | verifyTools(tools, expected.tools) 1446 | expect(openApiLookup).toEqual(expected.openApiLookup) 1447 | }) 1448 | }) 1449 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/openapi/file-upload.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types' 2 | 3 | /** 4 | * Identifies file upload parameters in an OpenAPI operation 5 | * @param operation The OpenAPI operation object to check 6 | * @returns Array of parameter names that are file uploads 7 | */ 8 | export function isFileUploadParameter(operation: OpenAPIV3.OperationObject): string[] { 9 | const fileParams: string[] = [] 10 | 11 | if (!operation.requestBody) return fileParams 12 | 13 | const requestBody = operation.requestBody as OpenAPIV3.RequestBodyObject 14 | const content = requestBody.content || {} 15 | 16 | // Check multipart/form-data content type for file uploads 17 | const multipartContent = content['multipart/form-data'] 18 | if (!multipartContent?.schema) return fileParams 19 | 20 | const schema = multipartContent.schema as OpenAPIV3.SchemaObject 21 | if (schema.type !== 'object' || !schema.properties) return fileParams 22 | 23 | // Look for properties with type: string, format: binary which indicates file uploads 24 | Object.entries(schema.properties).forEach(([propName, prop]) => { 25 | const schemaProp = prop as OpenAPIV3.SchemaObject 26 | if (schemaProp.type === 'string' && schemaProp.format === 'binary') { 27 | fileParams.push(propName) 28 | } 29 | 30 | // Check for array of files 31 | if (schemaProp.type === 'array' && schemaProp.items) { 32 | const itemSchema = schemaProp.items as OpenAPIV3.SchemaObject 33 | if (itemSchema.type === 'string' && itemSchema.format === 'binary') { 34 | fileParams.push(propName) 35 | } 36 | } 37 | }) 38 | 39 | return fileParams 40 | } 41 | -------------------------------------------------------------------------------- /src/openapi-mcp-server/openapi/parser.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' 2 | import type { JSONSchema7 as IJsonSchema } from 'json-schema' 3 | import type { ChatCompletionTool } from 'openai/resources/chat/completions' 4 | import type { Tool } from '@anthropic-ai/sdk/resources/messages/messages' 5 | 6 | type NewToolMethod = { 7 | name: string 8 | description: string 9 | inputSchema: IJsonSchema & { type: 'object' } 10 | returnSchema?: IJsonSchema 11 | } 12 | 13 | type FunctionParameters = { 14 | type: 'object' 15 | properties?: Record 16 | required?: string[] 17 | [key: string]: unknown 18 | } 19 | 20 | export class OpenAPIToMCPConverter { 21 | private schemaCache: Record = {} 22 | private nameCounter: number = 0 23 | 24 | constructor(private openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {} 25 | 26 | /** 27 | * Resolve a $ref reference to its schema in the openApiSpec. 28 | * Returns the raw OpenAPI SchemaObject or null if not found. 29 | */ 30 | private internalResolveRef(ref: string, resolvedRefs: Set): OpenAPIV3.SchemaObject | null { 31 | if (!ref.startsWith('#/')) { 32 | return null 33 | } 34 | if (resolvedRefs.has(ref)) { 35 | return null 36 | } 37 | 38 | const parts = ref.replace(/^#\//, '').split('/') 39 | let current: any = this.openApiSpec 40 | for (const part of parts) { 41 | current = current[part] 42 | if (!current) return null 43 | } 44 | resolvedRefs.add(ref) 45 | return current as OpenAPIV3.SchemaObject 46 | } 47 | 48 | /** 49 | * Convert an OpenAPI schema (or reference) into a JSON Schema object. 50 | * Uses caching and handles cycles by returning $ref nodes. 51 | */ 52 | convertOpenApiSchemaToJsonSchema( 53 | schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, 54 | resolvedRefs: Set, 55 | resolveRefs: boolean = false, 56 | ): IJsonSchema { 57 | if ('$ref' in schema) { 58 | const ref = schema.$ref 59 | if (!resolveRefs) { 60 | if (ref.startsWith('#/components/schemas/')) { 61 | return { 62 | $ref: ref.replace(/^#\/components\/schemas\//, '#/$defs/'), 63 | ...('description' in schema ? { description: schema.description as string } : {}), 64 | } 65 | } 66 | console.error(`Attempting to resolve ref ${ref} not found in components collection.`) 67 | // deliberate fall through 68 | } 69 | // Create base schema with $ref and description if present 70 | const refSchema: IJsonSchema = { $ref: ref } 71 | if ('description' in schema && schema.description) { 72 | refSchema.description = schema.description as string 73 | } 74 | 75 | // If already cached, return immediately with description 76 | if (this.schemaCache[ref]) { 77 | return this.schemaCache[ref] 78 | } 79 | 80 | const resolved = this.internalResolveRef(ref, resolvedRefs) 81 | if (!resolved) { 82 | // TODO: need extensive tests for this and we definitely need to handle the case of self references 83 | console.error(`Failed to resolve ref ${ref}`) 84 | return { 85 | $ref: ref.replace(/^#\/components\/schemas\//, '#/$defs/'), 86 | description: 'description' in schema ? ((schema.description as string) ?? '') : '', 87 | } 88 | } else { 89 | const converted = this.convertOpenApiSchemaToJsonSchema(resolved, resolvedRefs, resolveRefs) 90 | this.schemaCache[ref] = converted 91 | 92 | return converted 93 | } 94 | } 95 | 96 | // Handle inline schema 97 | const result: IJsonSchema = {} 98 | 99 | if (schema.type) { 100 | result.type = schema.type as IJsonSchema['type'] 101 | } 102 | 103 | // Convert binary format to uri-reference and enhance description 104 | if (schema.format === 'binary') { 105 | result.format = 'uri-reference' 106 | const binaryDesc = 'absolute paths to local files' 107 | result.description = schema.description ? `${schema.description} (${binaryDesc})` : binaryDesc 108 | } else { 109 | if (schema.format) { 110 | result.format = schema.format 111 | } 112 | if (schema.description) { 113 | result.description = schema.description 114 | } 115 | } 116 | 117 | if (schema.enum) { 118 | result.enum = schema.enum 119 | } 120 | 121 | if (schema.default !== undefined) { 122 | result.default = schema.default 123 | } 124 | 125 | // Handle object properties 126 | if (schema.type === 'object') { 127 | result.type = 'object' 128 | if (schema.properties) { 129 | result.properties = {} 130 | for (const [name, propSchema] of Object.entries(schema.properties)) { 131 | result.properties[name] = this.convertOpenApiSchemaToJsonSchema(propSchema, resolvedRefs, resolveRefs) 132 | } 133 | } 134 | if (schema.required) { 135 | result.required = schema.required 136 | } 137 | if (schema.additionalProperties === true || schema.additionalProperties === undefined) { 138 | result.additionalProperties = true 139 | } else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { 140 | result.additionalProperties = this.convertOpenApiSchemaToJsonSchema(schema.additionalProperties, resolvedRefs, resolveRefs) 141 | } else { 142 | result.additionalProperties = false 143 | } 144 | } 145 | 146 | // Handle arrays - ensure binary format conversion happens for array items too 147 | if (schema.type === 'array' && schema.items) { 148 | result.type = 'array' 149 | result.items = this.convertOpenApiSchemaToJsonSchema(schema.items, resolvedRefs, resolveRefs) 150 | } 151 | 152 | // oneOf, anyOf, allOf 153 | if (schema.oneOf) { 154 | result.oneOf = schema.oneOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs)) 155 | } 156 | if (schema.anyOf) { 157 | result.anyOf = schema.anyOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs)) 158 | } 159 | if (schema.allOf) { 160 | result.allOf = schema.allOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs)) 161 | } 162 | 163 | return result 164 | } 165 | 166 | convertToMCPTools(): { 167 | tools: Record 168 | openApiLookup: Record 169 | zip: Record 170 | } { 171 | const apiName = 'API' 172 | 173 | const openApiLookup: Record = {} 174 | const tools: Record = { 175 | [apiName]: { methods: [] }, 176 | } 177 | const zip: Record = {} 178 | for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) { 179 | if (!pathItem) continue 180 | 181 | for (const [method, operation] of Object.entries(pathItem)) { 182 | if (!this.isOperation(method, operation)) continue 183 | 184 | const mcpMethod = this.convertOperationToMCPMethod(operation, method, path) 185 | if (mcpMethod) { 186 | const uniqueName = this.ensureUniqueName(mcpMethod.name) 187 | mcpMethod.name = uniqueName 188 | tools[apiName]!.methods.push(mcpMethod) 189 | openApiLookup[apiName + '-' + uniqueName] = { ...operation, method, path } 190 | zip[apiName + '-' + uniqueName] = { openApi: { ...operation, method, path }, mcp: mcpMethod } 191 | } 192 | } 193 | } 194 | 195 | return { tools, openApiLookup, zip } 196 | } 197 | 198 | /** 199 | * Convert the OpenAPI spec to OpenAI's ChatCompletionTool format 200 | */ 201 | convertToOpenAITools(): ChatCompletionTool[] { 202 | const tools: ChatCompletionTool[] = [] 203 | 204 | for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) { 205 | if (!pathItem) continue 206 | 207 | for (const [method, operation] of Object.entries(pathItem)) { 208 | if (!this.isOperation(method, operation)) continue 209 | 210 | const parameters = this.convertOperationToJsonSchema(operation, method, path) 211 | const tool: ChatCompletionTool = { 212 | type: 'function', 213 | function: { 214 | name: operation.operationId!, 215 | description: operation.summary || operation.description || '', 216 | parameters: parameters as FunctionParameters, 217 | }, 218 | } 219 | tools.push(tool) 220 | } 221 | } 222 | 223 | return tools 224 | } 225 | 226 | /** 227 | * Convert the OpenAPI spec to Anthropic's Tool format 228 | */ 229 | convertToAnthropicTools(): Tool[] { 230 | const tools: Tool[] = [] 231 | 232 | for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) { 233 | if (!pathItem) continue 234 | 235 | for (const [method, operation] of Object.entries(pathItem)) { 236 | if (!this.isOperation(method, operation)) continue 237 | 238 | const parameters = this.convertOperationToJsonSchema(operation, method, path) 239 | const tool: Tool = { 240 | name: operation.operationId!, 241 | description: operation.summary || operation.description || '', 242 | input_schema: parameters as Tool['input_schema'], 243 | } 244 | tools.push(tool) 245 | } 246 | } 247 | 248 | return tools 249 | } 250 | 251 | private convertComponentsToJsonSchema(): Record { 252 | const components = this.openApiSpec.components || {} 253 | const schema: Record = {} 254 | for (const [key, value] of Object.entries(components.schemas || {})) { 255 | schema[key] = this.convertOpenApiSchemaToJsonSchema(value, new Set()) 256 | } 257 | return schema 258 | } 259 | /** 260 | * Helper method to convert an operation to a JSON Schema for parameters 261 | */ 262 | private convertOperationToJsonSchema( 263 | operation: OpenAPIV3.OperationObject, 264 | method: string, 265 | path: string, 266 | ): IJsonSchema & { type: 'object' } { 267 | const schema: IJsonSchema & { type: 'object' } = { 268 | type: 'object', 269 | properties: {}, 270 | required: [], 271 | $defs: this.convertComponentsToJsonSchema(), 272 | } 273 | 274 | // Handle parameters (path, query, header, cookie) 275 | if (operation.parameters) { 276 | for (const param of operation.parameters) { 277 | const paramObj = this.resolveParameter(param) 278 | if (paramObj && paramObj.schema) { 279 | const paramSchema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set()) 280 | // Merge parameter-level description if available 281 | if (paramObj.description) { 282 | paramSchema.description = paramObj.description 283 | } 284 | schema.properties![paramObj.name] = paramSchema 285 | if (paramObj.required) { 286 | schema.required!.push(paramObj.name) 287 | } 288 | } 289 | } 290 | } 291 | 292 | // Handle requestBody 293 | if (operation.requestBody) { 294 | const bodyObj = this.resolveRequestBody(operation.requestBody) 295 | if (bodyObj?.content) { 296 | if (bodyObj.content['application/json']?.schema) { 297 | const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set()) 298 | if (bodySchema.type === 'object' && bodySchema.properties) { 299 | for (const [name, propSchema] of Object.entries(bodySchema.properties)) { 300 | schema.properties![name] = propSchema 301 | } 302 | if (bodySchema.required) { 303 | schema.required!.push(...bodySchema.required) 304 | } 305 | } 306 | } 307 | } 308 | } 309 | 310 | return schema 311 | } 312 | 313 | private isOperation(method: string, operation: any): operation is OpenAPIV3.OperationObject { 314 | return ['get', 'post', 'put', 'delete', 'patch'].includes(method.toLowerCase()) 315 | } 316 | 317 | private isParameterObject(param: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject): param is OpenAPIV3.ParameterObject { 318 | return !('$ref' in param) 319 | } 320 | 321 | private isRequestBodyObject(body: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject): body is OpenAPIV3.RequestBodyObject { 322 | return !('$ref' in body) 323 | } 324 | 325 | private resolveParameter(param: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject): OpenAPIV3.ParameterObject | null { 326 | if (this.isParameterObject(param)) { 327 | return param 328 | } else { 329 | const resolved = this.internalResolveRef(param.$ref, new Set()) 330 | if (resolved && (resolved as OpenAPIV3.ParameterObject).name) { 331 | return resolved as OpenAPIV3.ParameterObject 332 | } 333 | } 334 | return null 335 | } 336 | 337 | private resolveRequestBody(body: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject): OpenAPIV3.RequestBodyObject | null { 338 | if (this.isRequestBodyObject(body)) { 339 | return body 340 | } else { 341 | const resolved = this.internalResolveRef(body.$ref, new Set()) 342 | if (resolved) { 343 | return resolved as OpenAPIV3.RequestBodyObject 344 | } 345 | } 346 | return null 347 | } 348 | 349 | private resolveResponse(response: OpenAPIV3.ResponseObject | OpenAPIV3.ReferenceObject): OpenAPIV3.ResponseObject | null { 350 | if ('$ref' in response) { 351 | const resolved = this.internalResolveRef(response.$ref, new Set()) 352 | if (resolved) { 353 | return resolved as OpenAPIV3.ResponseObject 354 | } else { 355 | return null 356 | } 357 | } 358 | return response 359 | } 360 | 361 | private convertOperationToMCPMethod(operation: OpenAPIV3.OperationObject, method: string, path: string): NewToolMethod | null { 362 | if (!operation.operationId) { 363 | console.warn(`Operation without operationId at ${method} ${path}`) 364 | return null 365 | } 366 | 367 | const methodName = operation.operationId 368 | 369 | const inputSchema: IJsonSchema & { type: 'object' } = { 370 | $defs: this.convertComponentsToJsonSchema(), 371 | type: 'object', 372 | properties: {}, 373 | required: [], 374 | } 375 | 376 | // Handle parameters (path, query, header, cookie) 377 | if (operation.parameters) { 378 | for (const param of operation.parameters) { 379 | const paramObj = this.resolveParameter(param) 380 | if (paramObj && paramObj.schema) { 381 | const schema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set(), false) 382 | // Merge parameter-level description if available 383 | if (paramObj.description) { 384 | schema.description = paramObj.description 385 | } 386 | inputSchema.properties![paramObj.name] = schema 387 | if (paramObj.required) { 388 | inputSchema.required!.push(paramObj.name) 389 | } 390 | } 391 | } 392 | } 393 | 394 | // Handle requestBody 395 | if (operation.requestBody) { 396 | const bodyObj = this.resolveRequestBody(operation.requestBody) 397 | if (bodyObj?.content) { 398 | // Handle multipart/form-data for file uploads 399 | // We convert the multipart/form-data schema to a JSON schema and we require 400 | // that the user passes in a string for each file that points to the local file 401 | if (bodyObj.content['multipart/form-data']?.schema) { 402 | const formSchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['multipart/form-data'].schema, new Set(), false) 403 | if (formSchema.type === 'object' && formSchema.properties) { 404 | for (const [name, propSchema] of Object.entries(formSchema.properties)) { 405 | inputSchema.properties![name] = propSchema 406 | } 407 | if (formSchema.required) { 408 | inputSchema.required!.push(...formSchema.required!) 409 | } 410 | } 411 | } 412 | // Handle application/json 413 | else if (bodyObj.content['application/json']?.schema) { 414 | const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set(), false) 415 | // Merge body schema into the inputSchema's properties 416 | if (bodySchema.type === 'object' && bodySchema.properties) { 417 | for (const [name, propSchema] of Object.entries(bodySchema.properties)) { 418 | inputSchema.properties![name] = propSchema 419 | } 420 | if (bodySchema.required) { 421 | inputSchema.required!.push(...bodySchema.required!) 422 | } 423 | } else { 424 | // If the request body is not an object, just put it under "body" 425 | inputSchema.properties!['body'] = bodySchema 426 | inputSchema.required!.push('body') 427 | } 428 | } 429 | } 430 | } 431 | 432 | // Build description including error responses 433 | let description = operation.summary || operation.description || '' 434 | if (operation.responses) { 435 | const errorResponses = Object.entries(operation.responses) 436 | .filter(([code]) => code.startsWith('4') || code.startsWith('5')) 437 | .map(([code, response]) => { 438 | const responseObj = this.resolveResponse(response) 439 | let errorDesc = responseObj?.description || '' 440 | return `${code}: ${errorDesc}` 441 | }) 442 | 443 | if (errorResponses.length > 0) { 444 | description += '\nError Responses:\n' + errorResponses.join('\n') 445 | } 446 | } 447 | 448 | // Extract return type (response schema) 449 | const returnSchema = this.extractResponseType(operation.responses) 450 | 451 | // Generate Zod schema from input schema 452 | try { 453 | // const zodSchemaStr = jsonSchemaToZod(inputSchema, { module: "cjs" }) 454 | // console.log(zodSchemaStr) 455 | // // Execute the function with the zod instance 456 | // const zodSchema = eval(zodSchemaStr) as z.ZodType 457 | 458 | return { 459 | name: methodName, 460 | description, 461 | inputSchema, 462 | ...(returnSchema ? { returnSchema } : {}), 463 | } 464 | } catch (error) { 465 | console.warn(`Failed to generate Zod schema for ${methodName}:`, error) 466 | // Fallback to a basic object schema 467 | return { 468 | name: methodName, 469 | description, 470 | inputSchema, 471 | ...(returnSchema ? { returnSchema } : {}), 472 | } 473 | } 474 | } 475 | 476 | private extractResponseType(responses: OpenAPIV3.ResponsesObject | undefined): IJsonSchema | null { 477 | // Look for a success response 478 | const successResponse = responses?.['200'] || responses?.['201'] || responses?.['202'] || responses?.['204'] 479 | if (!successResponse) return null 480 | 481 | const responseObj = this.resolveResponse(successResponse) 482 | if (!responseObj || !responseObj.content) return null 483 | 484 | if (responseObj.content['application/json']?.schema) { 485 | const returnSchema = this.convertOpenApiSchemaToJsonSchema(responseObj.content['application/json'].schema, new Set(), false) 486 | returnSchema['$defs'] = this.convertComponentsToJsonSchema() 487 | 488 | // Preserve the response description if available and not already set 489 | if (responseObj.description && !returnSchema.description) { 490 | returnSchema.description = responseObj.description 491 | } 492 | 493 | return returnSchema 494 | } 495 | 496 | // If no JSON response, fallback to a generic string or known formats 497 | if (responseObj.content['image/png'] || responseObj.content['image/jpeg']) { 498 | return { type: 'string', format: 'binary', description: responseObj.description || '' } 499 | } 500 | 501 | // Fallback 502 | return { type: 'string', description: responseObj.description || '' } 503 | } 504 | 505 | private ensureUniqueName(name: string): string { 506 | if (name.length <= 64) { 507 | return name 508 | } 509 | 510 | const truncatedName = name.slice(0, 64 - 5) // Reserve space for suffix 511 | const uniqueSuffix = this.generateUniqueSuffix() 512 | return `${truncatedName}-${uniqueSuffix}` 513 | } 514 | 515 | private generateUniqueSuffix(): string { 516 | this.nameCounter += 1 517 | return this.nameCounter.toString().padStart(4, '0') 518 | } 519 | } 520 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "sourceMap": true, 7 | "outDir": "./build", 8 | "target": "es2021", 9 | "lib": ["es2022"], 10 | "jsx": "react-jsx", 11 | "module": "es2022", 12 | "moduleResolution": "Bundler", 13 | "types": [ 14 | "node" 15 | ], 16 | "resolveJsonModule": true, 17 | "allowJs": true, 18 | "checkJs": false, 19 | "isolatedModules": true, 20 | "allowSyntheticDefaultImports": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "strict": true, 23 | "skipLibCheck": true 24 | }, 25 | "include": [ "test/**/*.ts", "scripts/**/*.ts", "src/**/*.ts"] 26 | } 27 | --------------------------------------------------------------------------------