#
tokens: 41513/50000 27/29 files (page 1/2)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 1 of 2. Use http://codebase.md/taewoong1378/notion-readonly-mcp-server?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .dockerignore
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── docs
│   └── images
│       ├── connections.png
│       ├── integrations-capabilities.png
│       ├── integrations-creation.png
│       └── notion-api-tools-comparison.png
├── examples
│   └── petstore-server.cjs
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── scripts
│   ├── build-cli.js
│   ├── notion-openapi.json
│   └── start-server.ts
├── src
│   ├── init-server.ts
│   └── openapi-mcp-server
│       ├── 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
│       ├── index.ts
│       ├── mcp
│       │   ├── __tests__
│       │   │   ├── one-pager.test.ts
│       │   │   └── proxy.test.ts
│       │   └── proxy.ts
│       ├── openapi
│       │   ├── __tests__
│       │   │   ├── file-upload.test.ts
│       │   │   ├── parser-multipart.test.ts
│       │   │   └── parser.test.ts
│       │   ├── file-upload.ts
│       │   └── parser.ts
│       └── README.md
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.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 | 
14 | .env
```

--------------------------------------------------------------------------------
/src/openapi-mcp-server/README.md:
--------------------------------------------------------------------------------

```markdown
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 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Notion ReadOnly MCP Server
  2 | 
  3 | This project implements an optimized read-only MCP server for the Notion API, focusing on performance and efficiency for AI assistants to query and retrieve Notion content.
  4 | 
  5 | <a href="https://glama.ai/mcp/servers/@Taewoong1378/notion-readonly-mcp-server">
  6 |   <img width="380" height="200" src="https://glama.ai/mcp/servers/@Taewoong1378/notion-readonly-mcp-server/badge" alt="Notion ReadOnly Server MCP server" />
  7 | </a>
  8 | 
  9 | ## Key Improvements
 10 | 
 11 | - **Read-Only Design**: Focused exclusively on data retrieval operations, ensuring safe access to Notion content.
 12 | - **Minimized Tool Set**: Reduced the number of exposed Notion API tools from 15+ to only 6 essential ones for document analysis.
 13 | - **Parallel Processing**: Enhanced performance by implementing asynchronous and parallel API requests for retrieving block content, significantly reducing response times.
 14 | - **Extended Database Access**: Added support for database, page property, and comment retrieval operations.
 15 | - **Optimized for AI Assistants**: Significantly reduced tool count addresses the "Too many tools can degrade performance" issue in AI assistants like Cursor, which limits models to approximately 40 tools.
 16 | 
 17 | ## Tool Comparison
 18 | 
 19 | This read-only implementation exposes far fewer tools compared to the standard Notion API integration, improving performance and compatibility with AI assistants:
 20 | 
 21 | ![Notion API Tools Comparison](docs/images/notion-api-tools-comparison.png)
 22 | 
 23 | The reduced tool set helps stay within the recommended tool limits for optimal AI assistant performance while still providing all essential functionality.
 24 | 
 25 | ## Installation
 26 | 
 27 | ### 1. Setting up Integration in Notion:
 28 | 
 29 | Go to https://www.notion.so/profile/integrations and create a new **internal** integration or select an existing one.
 30 | 
 31 | ![Creating a Notion Integration token](docs/images/integrations-creation.png)
 32 | 
 33 | While we limit the scope of Notion API's exposed to read-only operations, 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_.
 34 | 
 35 | For example, you can create a read-only integration token by giving only "Read content" access from the "Configuration" tab:
 36 | 
 37 | ![Notion Integration Token Capabilities showing Read content checked](docs/images/integrations-capabilities.png)
 38 | 
 39 | ### 2. Adding MCP config to your client:
 40 | 
 41 | #### Using npm:
 42 | 
 43 | Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json` (MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json`)
 44 | 
 45 | ```json
 46 | {
 47 |   "mcpServers": {
 48 |     "notionApi": {
 49 |       "command": "npx",
 50 |       "args": ["-y", "notion-readonly-mcp-server"],
 51 |       "env": {
 52 |         "OPENAPI_MCP_HEADERS": "{\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\" }"
 53 |       }
 54 |     }
 55 |   }
 56 | }
 57 | ```
 58 | 
 59 | #### Using Docker:
 60 | 
 61 | Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json`:
 62 | 
 63 | ```json
 64 | {
 65 |   "mcpServers": {
 66 |     "notionApi": {
 67 |       "command": "docker",
 68 |       "args": [
 69 |         "run",
 70 |         "--rm",
 71 |         "-i",
 72 |         "-e", "OPENAPI_MCP_HEADERS",
 73 |         "taewoong1378/notion-readonly-mcp-server"
 74 |       ],
 75 |       "env": {
 76 |         "OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer ntn_****\",\"Notion-Version\":\"2022-06-28\"}"
 77 |       }
 78 |     }
 79 |   }
 80 | }
 81 | ```
 82 | 
 83 | Don't forget to replace `ntn_****` with your integration secret. Find it from your integration configuration tab.
 84 | 
 85 | ### 3. Connecting content to integration:
 86 | 
 87 | Ensure relevant pages and databases are connected to your integration.
 88 | 
 89 | To do this, visit the page, click on the 3 dots, and select "Connect to integration".
 90 | 
 91 | ![Adding Integration Token to Notion Connections](docs/images/connections.png)
 92 | 
 93 | ## Available Tools
 94 | 
 95 | This optimized server exposes only essential read-only Notion API tools:
 96 | 
 97 | - `API-retrieve-a-page`: Get page information
 98 | - `API-get-block-children`: Get page content blocks (with parallel processing)
 99 | - `API-retrieve-a-block`: Get details about a specific block
100 | - `API-retrieve-a-database`: Get database information
101 | - `API-retrieve-a-comment`: Get comments on a page or block
102 | - `API-retrieve-a-page-property`: Get specific property information from a page
103 | - `API-get-one-pager`: **NEW!** Recursively retrieve a full Notion page with all its blocks, databases, and related content in a single call
104 | 
105 | By limiting to these 7 essential tools (compared to 15+ in the standard implementation), we ensure:
106 | 
107 | 1. Better performance in AI assistants like Cursor and Claude that have tool count limitations
108 | 2. Reduced cognitive load for AI models when choosing appropriate tools
109 | 3. Faster response times with fewer API options to consider
110 | 4. Enhanced security through minimized API surface area
111 | 
112 | ## Automatic Content Exploration
113 | 
114 | The new `API-get-one-pager` tool provides a powerful way to explore Notion pages without requiring multiple API calls:
115 | 
116 | - **Recursive retrieval**: Automatically traverses the entire page structure including nested blocks
117 | - **Parallel processing**: Fetches multiple blocks and their children simultaneously for maximum performance
118 | - **Intelligent caching**: Stores retrieved data to minimize redundant API calls
119 | - **Comprehensive content**: Includes pages, blocks, databases, comments, and detailed property information
120 | - **Customizable depth**: Control the level of recursion to balance between detail and performance
121 | 
122 | ### Using One Pager Tool
123 | 
124 | ```
125 | {
126 |   "page_id": "YOUR_PAGE_ID",
127 |   "maxDepth": 5,               // Optional: Maximum recursion depth (default: 5)
128 |   "includeDatabases": true,    // Optional: Include linked databases (default: true)
129 |   "includeComments": true,     // Optional: Include comments (default: true)
130 |   "includeProperties": true    // Optional: Include detailed page properties (default: true)
131 | }
132 | ```
133 | 
134 | This automatic exploration capability is especially useful for AI assistants that need to understand the entire content of a Notion page without making dozens of separate API calls, resulting in much faster and more efficient responses.
135 | 
136 | ## Asynchronous Processing
137 | 
138 | The server implements advanced parallel processing techniques for handling large Notion documents:
139 | 
140 | - Multiple requests are batched and processed concurrently
141 | - Pagination is handled automatically for block children
142 | - Results are efficiently aggregated before being returned
143 | - Console logging provides visibility into the process without affecting response format
144 | 
145 | ## Examples
146 | 
147 | 1. Using the following instruction:
148 | 
149 | ```
150 | Get the content of page 1a6b35e6e67f802fa7e1d27686f017f2
151 | ```
152 | 
153 | The AI will retrieve the page details efficiently with parallel processing of block content.
154 | 
155 | 2. Using database information:
156 | 
157 | ```
158 | Get the structure of database 8a6b35e6e67f802fa7e1d27686f017f2
159 | ```
160 | 
161 | ## Development
162 | 
163 | Build:
164 | 
165 | ```
166 | pnpm build
167 | ```
168 | 
169 | Execute:
170 | 
171 | ```
172 | pnpm dev
173 | ```
174 | 
175 | ## License
176 | 
177 | MIT
178 | 
179 | ## AI Assistant Performance Benefits
180 | 
181 | Modern AI assistants like Cursor and Claude have limitations on the number of tools they can effectively handle:
182 | 
183 | - Most models may not respect more than 40 tools in total
184 | - Too many tools can degrade overall performance and reasoning capabilities
185 | - Complex tool sets increase response latency and decision-making difficulty
186 | 
187 | This read-only implementation deliberately reduces the Notion API surface to address these limitations while preserving all essential functionality. The result is:
188 | 
189 | - Faster and more reliable responses from AI assistants
190 | - Improved accuracy when interacting with Notion content
191 | - Better overall performance through focused API design
```

--------------------------------------------------------------------------------
/src/openapi-mcp-server/auth/index.ts:
--------------------------------------------------------------------------------

```typescript
1 | export * from './types'
2 | export * from './template'
3 | 
```

--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------

```yaml
1 | services:
2 |   notion-mcp-server:
3 |     build: .
4 |     stdin_open: true
5 |     tty: true
6 |     restart: unless-stopped
7 | 
```

--------------------------------------------------------------------------------
/src/openapi-mcp-server/index.ts:
--------------------------------------------------------------------------------

```typescript
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/auth/types.ts:
--------------------------------------------------------------------------------

```typescript
 1 | export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
 2 | 
 3 | export interface AuthTemplate {
 4 |   url: string
 5 |   method: HttpMethod
 6 |   headers: Record<string, string>
 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<string, string>
26 | }
27 | 
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```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", "examples/**/*.cjs"]
26 | }
27 | 
```

--------------------------------------------------------------------------------
/src/openapi-mcp-server/auth/template.ts:
--------------------------------------------------------------------------------

```typescript
 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 | 
```

--------------------------------------------------------------------------------
/scripts/build-cli.js:
--------------------------------------------------------------------------------

```javascript
 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:
--------------------------------------------------------------------------------

```typescript
 1 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
 2 | import path from 'node:path'
 3 | import { fileURLToPath } from 'url'
 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 | 
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
 1 | # syntax=docker/dockerfile:1
 2 | 
 3 | # Use Node.js LTS as the base image
 4 | FROM node:20-slim AS builder
 5 | 
 6 | # Set working directory
 7 | WORKDIR /app
 8 | 
 9 | # Copy package.json and package-lock.json
10 | COPY package*.json ./
11 | 
12 | # Install dependencies
13 | RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts --omit-dev
14 | 
15 | # Copy source code
16 | COPY . .
17 | 
18 | # Build the package
19 | RUN --mount=type=cache,target=/root/.npm npm run build
20 | 
21 | # Install package globally
22 | RUN --mount=type=cache,target=/root/.npm npm link
23 | 
24 | # Minimal image for runtime
25 | FROM node:20-slim
26 | 
27 | # Copy built package from builder stage
28 | COPY scripts/notion-openapi.json /usr/local/scripts/
29 | COPY --from=builder /usr/local/lib/node_modules/@notionhq/notion-mcp-server /usr/local/lib/node_modules/@notionhq/notion-mcp-server
30 | COPY --from=builder /usr/local/bin/notion-mcp-server /usr/local/bin/notion-mcp-server
31 | 
32 | # Set default environment variables
33 | ENV OPENAPI_MCP_HEADERS="{}"
34 | 
35 | # Set entrypoint
36 | ENTRYPOINT ["notion-mcp-server"]
```

--------------------------------------------------------------------------------
/src/init-server.ts:
--------------------------------------------------------------------------------

```typescript
 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<OpenAPIV3.Document> {
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/openapi/file-upload.ts:
--------------------------------------------------------------------------------

```typescript
 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 | 
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "notion-readonly-mcp-server",
 3 |   "keywords": [
 4 |     "notion",
 5 |     "api",
 6 |     "mcp",
 7 |     "server",
 8 |     "read-only",
 9 |     "async"
10 |   ],
11 |   "version": "1.0.9",
12 |   "license": "MIT",
13 |   "type": "module",
14 |   "scripts": {
15 |     "build": "tsc -build && node scripts/build-cli.js",
16 |     "dev": "tsx watch scripts/start-server.ts"
17 |   },
18 |   "bin": {
19 |     "notion-mcp-server": "bin/cli.mjs"
20 |   },
21 |   "dependencies": {
22 |     "@modelcontextprotocol/sdk": "^1.8.0",
23 |     "axios": "^1.8.4",
24 |     "form-data": "^4.0.1",
25 |     "mustache": "^4.2.0",
26 |     "openapi-client-axios": "^7.5.5",
27 |     "openapi-schema-validator": "^12.1.3",
28 |     "openapi-types": "^12.1.3",
29 |     "which": "^5.0.0",
30 |     "zod": "3.24.1"
31 |   },
32 |   "devDependencies": {
33 |     "@anthropic-ai/sdk": "^0.33.1",
34 |     "@types/express": "^5.0.0",
35 |     "@types/js-yaml": "^4.0.9",
36 |     "@types/json-schema": "^7.0.15",
37 |     "@types/mustache": "^4.2.5",
38 |     "@types/node": "^20.17.16",
39 |     "@types/which": "^3.0.4",
40 |     "@vitest/coverage-v8": "3.1.1",
41 |     "body-parser": "^2.2.0",
42 |     "esbuild": "^0.25.2",
43 |     "express": "^4.21.2",
44 |     "multer": "1.4.5-lts.1",
45 |     "openai": "^4.91.1",
46 |     "tsx": "^4.19.3",
47 |     "typescript": "^5.8.2",
48 |     "vitest": "^3.1.1"
49 |   },
50 |   "description": "Optimized read-only MCP server for Notion API with asynchronous processing",
51 |   "main": "index.js",
52 |   "repository": {
53 |     "type": "git",
54 |     "url": "[email protected]:Taewoong1378/notion-readonly-mcp-server.git"
55 |   },
56 |   "author": "@taewoong1378",
57 |   "bugs": {
58 |     "url": "https://github.com/Taewoong1378/notion-readonly-mcp-server/issues"
59 |   },
60 |   "homepage": "https://github.com/Taewoong1378/notion-readonly-mcp-server#readme"
61 | }
62 | 
```

--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/__tests__/file-upload.test.ts:
--------------------------------------------------------------------------------

```typescript
  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/client/__tests__/http-client-upload.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import fs from 'fs'
  2 | import { OpenAPIV3 } from 'openapi-types'
  3 | import { beforeEach, describe, expect, it, vi } from 'vitest'
  4 | import { HttpClient } from '../http-client'
  5 | 
  6 | // 모킹 방식 변경
  7 | vi.mock('fs', () => {
  8 |   return {
  9 |     default: {
 10 |       createReadStream: vi.fn()
 11 |     },
 12 |     createReadStream: vi.fn()
 13 |   }
 14 | })
 15 | 
 16 | vi.mock('form-data', () => {
 17 |   const FormDataMock = vi.fn().mockImplementation(() => ({
 18 |     append: vi.fn(),
 19 |     getHeaders: vi.fn().mockReturnValue({ 'content-type': 'multipart/form-data; boundary=---123' })
 20 |   }))
 21 |   return {
 22 |     default: FormDataMock
 23 |   }
 24 | })
 25 | 
 26 | describe('HttpClient File Upload', () => {
 27 |   let client: HttpClient
 28 |   const mockApiInstance = {
 29 |     uploadFile: vi.fn(),
 30 |   }
 31 | 
 32 |   const baseConfig = {
 33 |     baseUrl: 'http://test.com',
 34 |     headers: {},
 35 |   }
 36 | 
 37 |   const mockOpenApiSpec: OpenAPIV3.Document = {
 38 |     openapi: '3.0.0',
 39 |     info: {
 40 |       title: 'Test API',
 41 |       version: '1.0.0',
 42 |     },
 43 |     paths: {
 44 |       '/upload': {
 45 |         post: {
 46 |           operationId: 'uploadFile',
 47 |           responses: {
 48 |             '200': {
 49 |               description: 'File uploaded successfully',
 50 |               content: {
 51 |                 'application/json': {
 52 |                   schema: {
 53 |                     type: 'object',
 54 |                     properties: {
 55 |                       success: {
 56 |                         type: 'boolean',
 57 |                       },
 58 |                     },
 59 |                   },
 60 |                 },
 61 |               },
 62 |             },
 63 |           },
 64 |           requestBody: {
 65 |             content: {
 66 |               'multipart/form-data': {
 67 |                 schema: {
 68 |                   type: 'object',
 69 |                   properties: {
 70 |                     file: {
 71 |                       type: 'string',
 72 |                       format: 'binary',
 73 |                     },
 74 |                     description: {
 75 |                       type: 'string',
 76 |                     },
 77 |                   },
 78 |                 },
 79 |               },
 80 |             },
 81 |           },
 82 |         },
 83 |       },
 84 |     },
 85 |   }
 86 | 
 87 |   beforeEach(() => {
 88 |     vi.clearAllMocks()
 89 |     client = new HttpClient(baseConfig, mockOpenApiSpec)
 90 |     // @ts-expect-error - Mock the private api property
 91 |     client['api'] = Promise.resolve(mockApiInstance)
 92 |   })
 93 | 
 94 |   it('should handle file uploads with FormData', async () => {
 95 |     const mockFileStream = { pipe: vi.fn() }
 96 |     
 97 |     // 모킹 방식 변경
 98 |     vi.mocked(fs.createReadStream).mockReturnValue(mockFileStream as any)
 99 | 
100 |     const uploadPath = mockOpenApiSpec.paths['/upload']
101 |     if (!uploadPath?.post) {
102 |       throw new Error('Upload path not found in spec')
103 |     }
104 |     const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string }
105 |     const params = {
106 |       file: '/path/to/test.txt',
107 |       description: 'Test file',
108 |     }
109 | 
110 |     mockApiInstance.uploadFile.mockResolvedValue({
111 |       data: { success: true },
112 |       status: 200,
113 |       headers: {},
114 |     })
115 | 
116 |     await client.executeOperation(operation, params)
117 | 
118 |     expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test.txt')
119 |     expect(mockApiInstance.uploadFile).toHaveBeenCalled()
120 |   })
121 | 
122 |   it('should throw error for invalid file path', async () => {
123 |     vi.mocked(fs.createReadStream).mockImplementation(() => {
124 |       throw new Error('File not found')
125 |     })
126 | 
127 |     const uploadPath = mockOpenApiSpec.paths['/upload']
128 |     if (!uploadPath?.post) {
129 |       throw new Error('Upload path not found in spec')
130 |     }
131 |     const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string }
132 |     const params = {
133 |       file: '/nonexistent/file.txt',
134 |       description: 'Test file',
135 |     }
136 | 
137 |     await expect(client.executeOperation(operation, params)).rejects.toThrow('Failed to read file at /nonexistent/file.txt')
138 |   })
139 | 
140 |   it('should handle multiple file uploads', async () => {
141 |     const mockFileStream1 = { pipe: vi.fn() }
142 |     const mockFileStream2 = { pipe: vi.fn() }
143 |     
144 |     // createReadStream 모킹을 시퀀스로 설정
145 |     vi.mocked(fs.createReadStream)
146 |       .mockReturnValueOnce(mockFileStream1 as any)
147 |       .mockReturnValueOnce(mockFileStream2 as any)
148 | 
149 |     const operation: OpenAPIV3.OperationObject = {
150 |       operationId: 'uploadFile',
151 |       responses: {
152 |         '200': {
153 |           description: 'Files uploaded successfully',
154 |           content: {
155 |             'application/json': {
156 |               schema: {
157 |                 type: 'object',
158 |                 properties: {
159 |                   success: {
160 |                     type: 'boolean',
161 |                   },
162 |                 },
163 |               },
164 |             },
165 |           },
166 |         },
167 |       },
168 |       requestBody: {
169 |         content: {
170 |           'multipart/form-data': {
171 |             schema: {
172 |               type: 'object',
173 |               properties: {
174 |                 file1: {
175 |                   type: 'string',
176 |                   format: 'binary',
177 |                 },
178 |                 file2: {
179 |                   type: 'string',
180 |                   format: 'binary',
181 |                 },
182 |                 description: {
183 |                   type: 'string',
184 |                 },
185 |               },
186 |             },
187 |           },
188 |         },
189 |       },
190 |     }
191 | 
192 |     const params = {
193 |       file1: '/path/to/test1.txt',
194 |       file2: '/path/to/test2.txt',
195 |       description: 'Test files',
196 |     }
197 | 
198 |     mockApiInstance.uploadFile.mockResolvedValue({
199 |       data: { success: true },
200 |       status: 200,
201 |       headers: {},
202 |     })
203 | 
204 |     await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, params)
205 | 
206 |     expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test1.txt')
207 |     expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test2.txt')
208 |     expect(mockApiInstance.uploadFile).toHaveBeenCalled()
209 |   })
210 | })
211 | 
```

--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type express from 'express'
  2 | import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  3 | import { HttpClient } from '../http-client'
  4 | //@ts-ignore
  5 | import axios from 'axios'
  6 | import type { OpenAPIV3 } from 'openapi-types'
  7 | import { createPetstoreServer } from '../../../../examples/petstore-server.cjs'
  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<typeof express>
 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<Pet[]>(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<Pet[]>(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<Pet>(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<Pet>(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<Pet>(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<Pet>(
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/http-client.ts:
--------------------------------------------------------------------------------

```typescript
  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 { isFileUploadParameter } from '../openapi/file-upload'
  7 | 
  8 | export type HttpClientConfig = {
  9 |   baseUrl: string
 10 |   headers?: Record<string, string>
 11 | }
 12 | 
 13 | export type HttpClientResponse<T = any> = {
 14 |   data: T
 15 |   status: number
 16 |   headers: Headers
 17 | }
 18 | 
 19 | export class HttpClientError extends Error {
 20 |   constructor(
 21 |     message: string,
 22 |     public status: number,
 23 |     public data: any,
 24 |     public headers?: Headers,
 25 |   ) {
 26 |     super(`${status} ${message}`)
 27 |     this.name = 'HttpClientError'
 28 |   }
 29 | }
 30 | 
 31 | export class HttpClient {
 32 |   private api: Promise<AxiosInstance>
 33 |   private client: OpenAPIClientAxios
 34 | 
 35 |   constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {
 36 |     // @ts-expect-error
 37 |     this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({
 38 |       definition: openApiSpec,
 39 |       axiosConfigDefaults: {
 40 |         baseURL: config.baseUrl,
 41 |         headers: {
 42 |           'Content-Type': 'application/json',
 43 |           'User-Agent': 'notion-mcp-server',
 44 |           ...config.headers,
 45 |         },
 46 |       },
 47 |     })
 48 |     this.api = this.client.init()
 49 |   }
 50 | 
 51 |   private async prepareFileUpload(operation: OpenAPIV3.OperationObject, params: Record<string, any>): Promise<FormData | null> {
 52 |     const fileParams = isFileUploadParameter(operation)
 53 |     if (fileParams.length === 0) return null
 54 | 
 55 |     const formData = new FormData()
 56 | 
 57 |     // Handle file uploads
 58 |     for (const param of fileParams) {
 59 |       const filePath = params[param]
 60 |       if (!filePath) {
 61 |         throw new Error(`File path must be provided for parameter: ${param}`)
 62 |       }
 63 |       switch (typeof filePath) {
 64 |         case 'string':
 65 |           addFile(param, filePath)
 66 |           break
 67 |         case 'object':
 68 |           if(Array.isArray(filePath)) {
 69 |             let fileCount = 0
 70 |             for(const file of filePath) {
 71 |               addFile(param, file)
 72 |               fileCount++
 73 |             }
 74 |             break
 75 |           }
 76 |           //deliberate fallthrough
 77 |         default:
 78 |           throw new Error(`Unsupported file type: ${typeof filePath}`)
 79 |       }
 80 |       function addFile(name: string, filePath: string) {
 81 |           try {
 82 |             const fileStream = fs.createReadStream(filePath)
 83 |             formData.append(name, fileStream)
 84 |         } catch (error) {
 85 |           throw new Error(`Failed to read file at ${filePath}: ${error}`)
 86 |         }
 87 |       }
 88 |     }
 89 | 
 90 |     // Add non-file parameters to form data
 91 |     for (const [key, value] of Object.entries(params)) {
 92 |       if (!fileParams.includes(key)) {
 93 |         formData.append(key, value)
 94 |       }
 95 |     }
 96 | 
 97 |     return formData
 98 |   }
 99 | 
100 |   /**
101 |    * Execute an OpenAPI operation
102 |    */
103 |   async executeOperation<T = any>(
104 |     operation: OpenAPIV3.OperationObject & { method: string; path: string },
105 |     params: Record<string, any> = {},
106 |   ): Promise<HttpClientResponse<T>> {
107 |     const api = await this.api
108 |     const operationId = operation.operationId
109 |     if (!operationId) {
110 |       throw new Error('Operation ID is required')
111 |     }
112 | 
113 |     // Handle file uploads if present
114 |     const formData = await this.prepareFileUpload(operation, params)
115 | 
116 |     // Separate parameters based on their location
117 |     const urlParameters: Record<string, any> = {}
118 |     const bodyParams: Record<string, any> = formData || { ...params }
119 | 
120 |     // Extract path and query parameters based on operation definition
121 |     if (operation.parameters) {
122 |       for (const param of operation.parameters) {
123 |         if ('name' in param && param.name && param.in) {
124 |           if (param.in === 'path' || param.in === 'query') {
125 |             if (params[param.name] !== undefined) {
126 |               urlParameters[param.name] = params[param.name]
127 |               if (!formData) {
128 |                 delete bodyParams[param.name]
129 |               }
130 |             }
131 |           }
132 |         }
133 |       }
134 |     }
135 | 
136 |     // Add all parameters as url parameters if there is no requestBody defined
137 |     if (!operation.requestBody && !formData) {
138 |       for (const key in bodyParams) {
139 |         if (bodyParams[key] !== undefined) {
140 |           urlParameters[key] = bodyParams[key]
141 |           delete bodyParams[key]
142 |         }
143 |       }
144 |     }
145 | 
146 |     const operationFn = (api as any)[operationId]
147 |     if (!operationFn) {
148 |       throw new Error(`Operation ${operationId} not found`)
149 |     }
150 | 
151 |     try {
152 |       // If we have form data, we need to set the correct headers
153 |       const hasBody = Object.keys(bodyParams).length > 0
154 |       const headers = formData
155 |         ? formData.getHeaders()
156 |         : { ...(hasBody ? { 'Content-Type': 'application/json' } : { 'Content-Type': null }) }
157 |       const requestConfig = {
158 |         headers: {
159 |           ...headers,
160 |         },
161 |       }
162 | 
163 |       // first argument is url parameters, second is body parameters
164 |       const response = await operationFn(urlParameters, hasBody ? bodyParams : undefined, requestConfig)
165 | 
166 |       // Convert axios headers to Headers object
167 |       const responseHeaders = new Headers()
168 |       Object.entries(response.headers).forEach(([key, value]) => {
169 |         if (value) responseHeaders.append(key, value.toString())
170 |       })
171 | 
172 |       return {
173 |         data: response.data,
174 |         status: response.status,
175 |         headers: responseHeaders,
176 |       }
177 |     } catch (error: any) {
178 |       if (error.response) {
179 |         console.error('Error in http client', error)
180 |         const headers = new Headers()
181 |         Object.entries(error.response.headers).forEach(([key, value]) => {
182 |           if (value) headers.append(key, value.toString())
183 |         })
184 | 
185 |         throw new HttpClientError(error.response.statusText || 'Request failed', error.response.status, error.response.data, headers)
186 |       }
187 |       throw error
188 |     }
189 |   }
190 | }
191 | 
```

--------------------------------------------------------------------------------
/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
  2 | import { OpenAPIV3 } from 'openapi-types'
  3 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  4 | import { HttpClient } from '../../client/http-client'
  5 | import { MCPProxy } from '../proxy'
  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<typeof vi.fn>).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 |       ).resolves.toEqual({
133 |         content: [
134 |           {
135 |             type: 'text',
136 |             text: JSON.stringify({
137 |               status: 'error',
138 |               message: 'Method nonExistentMethod not found.',
139 |               code: 404
140 |             }),
141 |           },
142 |         ],
143 |       })
144 |     })
145 | 
146 |     it('should handle tool names exceeding 64 characters', async () => {
147 |       // Mock HttpClient response
148 |       const mockResponse = {
149 |         data: { message: 'success' },
150 |         status: 200,
151 |         headers: new Headers({
152 |           'content-type': 'application/json'
153 |         })
154 |       };
155 |       (HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
156 | 
157 |       // Set up the openApiLookup with a long tool name
158 |       const longToolName = 'a'.repeat(65)
159 |       const truncatedToolName = longToolName.slice(0, 64)
160 |       ;(proxy as any).openApiLookup = {
161 |         [truncatedToolName]: {
162 |           operationId: longToolName,
163 |           responses: { '200': { description: 'Success' } },
164 |           method: 'get',
165 |           path: '/test'
166 |         }
167 |       };
168 | 
169 |       const server = (proxy as any).server;
170 |       const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function');
171 |       const callToolHandler = handlers[1];
172 | 
173 |       const result = await callToolHandler({
174 |         params: {
175 |           name: truncatedToolName,
176 |           arguments: {}
177 |         }
178 |       })
179 | 
180 |       expect(result).toEqual({
181 |         content: [
182 |           {
183 |             type: 'text',
184 |             text: JSON.stringify({ message: 'success' })
185 |           }
186 |         ]
187 |       })
188 |     })
189 |   })
190 | 
191 |   describe('getContentType', () => {
192 |     it('should return correct content type for different headers', () => {
193 |       const getContentType = (proxy as any).getContentType.bind(proxy)
194 | 
195 |       expect(getContentType(new Headers({ 'content-type': 'text/plain' }))).toBe('text')
196 |       expect(getContentType(new Headers({ 'content-type': 'application/json' }))).toBe('text')
197 |       expect(getContentType(new Headers({ 'content-type': 'image/jpeg' }))).toBe('image')
198 |       expect(getContentType(new Headers({ 'content-type': 'application/octet-stream' }))).toBe('binary')
199 |       expect(getContentType(new Headers())).toBe('binary')
200 |     })
201 |   })
202 | 
203 |   describe('parseHeadersFromEnv', () => {
204 |     const originalEnv = process.env
205 | 
206 |     beforeEach(() => {
207 |       process.env = { ...originalEnv }
208 |     })
209 | 
210 |     afterEach(() => {
211 |       process.env = originalEnv
212 |     })
213 | 
214 |     it('should parse valid JSON headers from env', () => {
215 |       process.env.OPENAPI_MCP_HEADERS = JSON.stringify({
216 |         Authorization: 'Bearer token123',
217 |         'X-Custom-Header': 'test',
218 |       })
219 | 
220 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
221 |       expect(HttpClient).toHaveBeenCalledWith(
222 |         expect.objectContaining({
223 |           headers: {
224 |             Authorization: 'Bearer token123',
225 |             'X-Custom-Header': 'test',
226 |           },
227 |         }),
228 |         expect.anything(),
229 |       )
230 |     })
231 | 
232 |     it('should return empty object when env var is not set', () => {
233 |       delete process.env.OPENAPI_MCP_HEADERS
234 | 
235 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
236 |       expect(HttpClient).toHaveBeenCalledWith(
237 |         expect.objectContaining({
238 |           headers: {},
239 |         }),
240 |         expect.anything(),
241 |       )
242 |     })
243 | 
244 |     it('should return empty object and warn on invalid JSON', () => {
245 |       const consoleSpy = vi.spyOn(console, 'warn')
246 |       process.env.OPENAPI_MCP_HEADERS = 'invalid json'
247 | 
248 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
249 |       expect(HttpClient).toHaveBeenCalledWith(
250 |         expect.objectContaining({
251 |           headers: {},
252 |         }),
253 |         expect.anything(),
254 |       )
255 |       expect(consoleSpy).toHaveBeenCalledWith('Failed to parse OPENAPI_MCP_HEADERS environment variable:', expect.any(Error))
256 |     })
257 | 
258 |     it('should return empty object and warn on non-object JSON', () => {
259 |       const consoleSpy = vi.spyOn(console, 'warn')
260 |       process.env.OPENAPI_MCP_HEADERS = '"string"'
261 | 
262 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
263 |       expect(HttpClient).toHaveBeenCalledWith(
264 |         expect.objectContaining({
265 |           headers: {},
266 |         }),
267 |         expect.anything(),
268 |       )
269 |       expect(consoleSpy).toHaveBeenCalledWith('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', 'string')
270 |     })
271 |   })
272 |   describe('connect', () => {
273 |     it('should connect to transport', async () => {
274 |       const mockTransport = {} as Transport
275 |       await proxy.connect(mockTransport)
276 | 
277 |       const server = (proxy as any).server
278 |       expect(server.connect).toHaveBeenCalledWith(mockTransport)
279 |     })
280 |   })
281 | })
282 | 
```

--------------------------------------------------------------------------------
/src/openapi-mcp-server/mcp/__tests__/one-pager.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { OpenAPIV3 } from 'openapi-types'
  2 | import { beforeEach, describe, expect, it, vi } from 'vitest'
  3 | import { HttpClient } from '../../client/http-client'
  4 | import { MCPProxy } from '../proxy'
  5 | 
  6 | // Mock the dependencies
  7 | vi.mock('../../client/http-client')
  8 | vi.mock('@modelcontextprotocol/sdk/server/index.js')
  9 | 
 10 | describe('MCPProxy - One Pager Functionality', () => {
 11 |   let proxy: MCPProxy
 12 |   let mockOpenApiSpec: OpenAPIV3.Document
 13 | 
 14 |   beforeEach(() => {
 15 |     // Reset all mocks
 16 |     vi.clearAllMocks()
 17 | 
 18 |     // Setup OpenAPI spec for testing
 19 |     mockOpenApiSpec = {
 20 |       openapi: '3.0.0',
 21 |       servers: [{ url: 'http://localhost:3000' }],
 22 |       info: {
 23 |         title: 'Notion API',
 24 |         version: '1.0.0',
 25 |       },
 26 |       paths: {
 27 |         '/v1/pages/{page_id}': {
 28 |           get: {
 29 |             operationId: 'retrieve-a-page',
 30 |             parameters: [
 31 |               {
 32 |                 name: 'page_id',
 33 |                 in: 'path',
 34 |                 required: true,
 35 |                 schema: { type: 'string' }
 36 |               }
 37 |             ],
 38 |             responses: {
 39 |               '200': {
 40 |                 description: 'Success',
 41 |               },
 42 |             },
 43 |           },
 44 |         },
 45 |         '/v1/blocks/{block_id}/children': {
 46 |           get: {
 47 |             operationId: 'get-block-children',
 48 |             parameters: [
 49 |               {
 50 |                 name: 'block_id',
 51 |                 in: 'path',
 52 |                 required: true,
 53 |                 schema: { type: 'string' }
 54 |               },
 55 |               {
 56 |                 name: 'page_size',
 57 |                 in: 'query',
 58 |                 schema: { type: 'integer' }
 59 |               }
 60 |             ],
 61 |             responses: {
 62 |               '200': {
 63 |                 description: 'Success',
 64 |               },
 65 |             },
 66 |           },
 67 |         },
 68 |         '/v1/blocks/{block_id}': {
 69 |           get: {
 70 |             operationId: 'retrieve-a-block',
 71 |             parameters: [
 72 |               {
 73 |                 name: 'block_id',
 74 |                 in: 'path',
 75 |                 required: true,
 76 |                 schema: { type: 'string' }
 77 |               }
 78 |             ],
 79 |             responses: {
 80 |               '200': {
 81 |                 description: 'Success',
 82 |               },
 83 |             },
 84 |           },
 85 |         },
 86 |         '/v1/databases/{database_id}': {
 87 |           get: {
 88 |             operationId: 'retrieve-a-database',
 89 |             parameters: [
 90 |               {
 91 |                 name: 'database_id',
 92 |                 in: 'path',
 93 |                 required: true,
 94 |                 schema: { type: 'string' }
 95 |               }
 96 |             ],
 97 |             responses: {
 98 |               '200': {
 99 |                 description: 'Success',
100 |               },
101 |             },
102 |           },
103 |         },
104 |         '/v1/comments': {
105 |           get: {
106 |             operationId: 'retrieve-a-comment',
107 |             parameters: [
108 |               {
109 |                 name: 'block_id',
110 |                 in: 'query',
111 |                 required: true,
112 |                 schema: { type: 'string' }
113 |               }
114 |             ],
115 |             responses: {
116 |               '200': {
117 |                 description: 'Success',
118 |               },
119 |             },
120 |           },
121 |         },
122 |         '/v1/pages/{page_id}/properties/{property_id}': {
123 |           get: {
124 |             operationId: 'retrieve-a-page-property',
125 |             parameters: [
126 |               {
127 |                 name: 'page_id',
128 |                 in: 'path',
129 |                 required: true,
130 |                 schema: { type: 'string' }
131 |               },
132 |               {
133 |                 name: 'property_id',
134 |                 in: 'path',
135 |                 required: true,
136 |                 schema: { type: 'string' }
137 |               }
138 |             ],
139 |             responses: {
140 |               '200': {
141 |                 description: 'Success',
142 |               },
143 |             },
144 |           },
145 |         },
146 |       },
147 |     }
148 | 
149 |     proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
150 |   })
151 | 
152 |   describe('handleOnePagerRequest', () => {
153 |     it('should recursively retrieve page content', async () => {
154 |       // Set up mocks for each API response
155 | 
156 |       // 1. Mock page response
157 |       const mockPageResponse = {
158 |         data: {
159 |           object: 'page',
160 |           id: 'test-page-id',
161 |           properties: {
162 |             title: {
163 |               id: 'title',
164 |               type: 'title',
165 |               title: [{ type: 'text', text: { content: 'Test Page' } }]
166 |             }
167 |           },
168 |           has_children: true
169 |         },
170 |         status: 200,
171 |         headers: new Headers({ 'content-type': 'application/json' }),
172 |       }
173 | 
174 |       // 2. Mock block children response
175 |       const mockBlocksResponse = {
176 |         data: {
177 |           object: 'list',
178 |           results: [
179 |             {
180 |               object: 'block',
181 |               id: 'block-1',
182 |               type: 'paragraph',
183 |               has_children: false,
184 |               paragraph: {
185 |                 rich_text: [{ type: 'text', text: { content: 'Test paragraph' } }]
186 |               }
187 |             },
188 |             {
189 |               object: 'block',
190 |               id: 'block-2',
191 |               type: 'child_database',
192 |               has_children: false,
193 |               child_database: {
194 |                 database_id: 'db-1'
195 |               }
196 |             }
197 |           ],
198 |           next_cursor: null,
199 |           has_more: false
200 |         },
201 |         status: 200,
202 |         headers: new Headers({ 'content-type': 'application/json' }),
203 |       }
204 | 
205 |       // 3. Mock database response
206 |       const mockDatabaseResponse = {
207 |         data: {
208 |           object: 'database',
209 |           id: 'db-1',
210 |           title: [{ type: 'text', text: { content: 'Test Database' } }],
211 |           properties: {
212 |             Name: {
213 |               id: 'title',
214 |               type: 'title',
215 |               title: {}
216 |             }
217 |           }
218 |         },
219 |         status: 200,
220 |         headers: new Headers({ 'content-type': 'application/json' }),
221 |       }
222 | 
223 |       // 4. Mock comments response
224 |       const mockCommentsResponse = {
225 |         data: {
226 |           object: 'list',
227 |           results: [
228 |             {
229 |               object: 'comment',
230 |               id: 'comment-1',
231 |               rich_text: [{ type: 'text', text: { content: 'Test comment' } }]
232 |             }
233 |           ],
234 |           has_more: false
235 |         },
236 |         status: 200,
237 |         headers: new Headers({ 'content-type': 'application/json' }),
238 |       }
239 | 
240 |       // Set up the mock API responses
241 |       const executeOperationMock = HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>
242 |       
243 |       executeOperationMock.mockImplementation((operation, params) => {
244 |         if (operation.operationId === 'retrieve-a-page') {
245 |           return Promise.resolve(mockPageResponse)
246 |         } else if (operation.operationId === 'get-block-children') {
247 |           return Promise.resolve(mockBlocksResponse)
248 |         } else if (operation.operationId === 'retrieve-a-database') {
249 |           return Promise.resolve(mockDatabaseResponse)
250 |         } else if (operation.operationId === 'retrieve-a-comment') {
251 |           return Promise.resolve(mockCommentsResponse)
252 |         }
253 |         return Promise.resolve({ data: {}, status: 200, headers: new Headers() })
254 |       })
255 | 
256 |       // Set up openApiLookup with our test operations
257 |       const openApiLookup = {
258 |         'API-retrieve-a-page': {
259 |           operationId: 'retrieve-a-page',
260 |           method: 'get',
261 |           path: '/v1/pages/{page_id}',
262 |         },
263 |         'API-get-block-children': {
264 |           operationId: 'get-block-children',
265 |           method: 'get',
266 |           path: '/v1/blocks/{block_id}/children',
267 |         },
268 |         'API-retrieve-a-database': {
269 |           operationId: 'retrieve-a-database',
270 |           method: 'get',
271 |           path: '/v1/databases/{database_id}',
272 |         },
273 |         'API-retrieve-a-comment': {
274 |           operationId: 'retrieve-a-comment',
275 |           method: 'get',
276 |           path: '/v1/comments',
277 |         },
278 |       }
279 |       ;(proxy as any).openApiLookup = openApiLookup
280 | 
281 |       // Get the server request handlers
282 |       const server = (proxy as any).server
283 |       const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
284 |       const callToolHandler = handlers[1]
285 | 
286 |       // Call the get-one-pager tool
287 |       const result = await callToolHandler({
288 |         params: {
289 |           name: 'API-get-one-pager',
290 |           arguments: {
291 |             page_id: 'test-page-id',
292 |             maxDepth: 2,
293 |             includeDatabases: true,
294 |             includeComments: true
295 |           },
296 |         },
297 |       })
298 | 
299 |       // Parse the result
300 |       const onePagerData = JSON.parse(result.content[0].text)
301 | 
302 |       // Verify the structure of the One Pager result
303 |       expect(onePagerData).toHaveProperty('id', 'test-page-id')
304 |       expect(onePagerData).toHaveProperty('content')
305 |       
306 |       // Verify that recursive content was retrieved
307 |       expect(onePagerData.content).toBeInstanceOf(Array)
308 |       expect(onePagerData.content.length).toBeGreaterThan(0)
309 |       
310 |       // Verify that at least one comment was retrieved
311 |       expect(onePagerData).toHaveProperty('comments')
312 |       expect(onePagerData.comments.results.length).toBeGreaterThan(0)
313 |       
314 |       // Verify database information was retrieved
315 |       const databaseBlock = onePagerData.content.find((block: any) => block.type === 'child_database')
316 |       expect(databaseBlock).toBeDefined()
317 |       expect(databaseBlock).toHaveProperty('database')
318 |       expect(databaseBlock.database).toHaveProperty('id', 'db-1')
319 |     })
320 |   })
321 | }) 
```

--------------------------------------------------------------------------------
/examples/petstore-server.cjs:
--------------------------------------------------------------------------------

```
  1 | const express = require('express')
  2 | const bodyParser = require('body-parser')
  3 | 
  4 | // 메모리에 저장할 데이터
  5 | let pets = [
  6 |   {
  7 |     id: 1,
  8 |     name: 'Max',
  9 |     species: 'Dog',
 10 |     age: 3,
 11 |     status: 'available'
 12 |   },
 13 |   {
 14 |     id: 2,
 15 |     name: 'Whiskers',
 16 |     species: 'Cat',
 17 |     age: 2,
 18 |     status: 'pending'
 19 |   },
 20 |   {
 21 |     id: 3,
 22 |     name: 'Goldie',
 23 |     species: 'Fish',
 24 |     age: 1,
 25 |     status: 'sold'
 26 |   }
 27 | ]
 28 | 
 29 | // 다음 ID 추적용
 30 | let nextId = 4
 31 | 
 32 | /**
 33 |  * Petstore 서버 생성 함수
 34 |  * @param {number} port 서버가 실행될 포트
 35 |  * @returns {Express} Express 서버 인스턴스
 36 |  */
 37 | function createPetstoreServer(port) {
 38 |   const app = express()
 39 | 
 40 |   // Middleware
 41 |   app.use(bodyParser.json())
 42 | 
 43 |   // OpenAPI spec 제공
 44 |   app.get('/openapi.json', (req, res) => {
 45 |     res.json({
 46 |       openapi: '3.0.0',
 47 |       info: {
 48 |         title: 'Petstore API',
 49 |         version: '1.0.0',
 50 |         description: 'A simple petstore API for testing'
 51 |       },
 52 |       servers: [
 53 |         {
 54 |           url: `http://localhost:${port}`
 55 |         }
 56 |       ],
 57 |       paths: {
 58 |         '/pets': {
 59 |           get: {
 60 |             operationId: 'listPets',
 61 |             summary: 'List all pets',
 62 |             parameters: [
 63 |               {
 64 |                 name: 'status',
 65 |                 in: 'query',
 66 |                 required: false,
 67 |                 schema: {
 68 |                   type: 'string',
 69 |                   enum: ['available', 'pending', 'sold']
 70 |                 }
 71 |               }
 72 |             ],
 73 |             responses: {
 74 |               '200': {
 75 |                 description: 'A list of pets',
 76 |                 content: {
 77 |                   'application/json': {
 78 |                     schema: {
 79 |                       type: 'array',
 80 |                       items: {
 81 |                         $ref: '#/components/schemas/Pet'
 82 |                       }
 83 |                     }
 84 |                   }
 85 |                 }
 86 |               }
 87 |             }
 88 |           },
 89 |           post: {
 90 |             operationId: 'createPet',
 91 |             summary: 'Create a pet',
 92 |             requestBody: {
 93 |               content: {
 94 |                 'application/json': {
 95 |                   schema: {
 96 |                     $ref: '#/components/schemas/NewPet'
 97 |                   }
 98 |                 }
 99 |               },
100 |               required: true
101 |             },
102 |             responses: {
103 |               '201': {
104 |                 description: 'Pet created',
105 |                 content: {
106 |                   'application/json': {
107 |                     schema: {
108 |                       $ref: '#/components/schemas/Pet'
109 |                     }
110 |                   }
111 |                 }
112 |               }
113 |             }
114 |           }
115 |         },
116 |         '/pets/{id}': {
117 |           get: {
118 |             operationId: 'getPet',
119 |             summary: 'Get a pet by ID',
120 |             parameters: [
121 |               {
122 |                 name: 'id',
123 |                 in: 'path',
124 |                 required: true,
125 |                 schema: {
126 |                   type: 'integer'
127 |                 }
128 |               }
129 |             ],
130 |             responses: {
131 |               '200': {
132 |                 description: 'A pet',
133 |                 content: {
134 |                   'application/json': {
135 |                     schema: {
136 |                       $ref: '#/components/schemas/Pet'
137 |                     }
138 |                   }
139 |                 }
140 |               },
141 |               '404': {
142 |                 description: 'Pet not found',
143 |                 content: {
144 |                   'application/json': {
145 |                     schema: {
146 |                       $ref: '#/components/schemas/Error'
147 |                     }
148 |                   }
149 |                 }
150 |               }
151 |             }
152 |           },
153 |           put: {
154 |             operationId: 'updatePet',
155 |             summary: 'Update a pet',
156 |             parameters: [
157 |               {
158 |                 name: 'id',
159 |                 in: 'path',
160 |                 required: true,
161 |                 schema: {
162 |                   type: 'integer'
163 |                 }
164 |               }
165 |             ],
166 |             requestBody: {
167 |               content: {
168 |                 'application/json': {
169 |                   schema: {
170 |                     $ref: '#/components/schemas/PetUpdate'
171 |                   }
172 |                 }
173 |               },
174 |               required: true
175 |             },
176 |             responses: {
177 |               '200': {
178 |                 description: 'Pet updated',
179 |                 content: {
180 |                   'application/json': {
181 |                     schema: {
182 |                       $ref: '#/components/schemas/Pet'
183 |                     }
184 |                   }
185 |                 }
186 |               },
187 |               '404': {
188 |                 description: 'Pet not found',
189 |                 content: {
190 |                   'application/json': {
191 |                     schema: {
192 |                       $ref: '#/components/schemas/Error'
193 |                     }
194 |                   }
195 |                 }
196 |               }
197 |             }
198 |           },
199 |           delete: {
200 |             operationId: 'deletePet',
201 |             summary: 'Delete a pet',
202 |             parameters: [
203 |               {
204 |                 name: 'id',
205 |                 in: 'path',
206 |                 required: true,
207 |                 schema: {
208 |                   type: 'integer'
209 |                 }
210 |               }
211 |             ],
212 |             responses: {
213 |               '204': {
214 |                 description: 'Pet deleted'
215 |               },
216 |               '404': {
217 |                 description: 'Pet not found',
218 |                 content: {
219 |                   'application/json': {
220 |                     schema: {
221 |                       $ref: '#/components/schemas/Error'
222 |                     }
223 |                   }
224 |                 }
225 |               }
226 |             }
227 |           }
228 |         }
229 |       },
230 |       components: {
231 |         schemas: {
232 |           Pet: {
233 |             type: 'object',
234 |             required: ['id', 'name', 'species', 'status'],
235 |             properties: {
236 |               id: {
237 |                 type: 'integer'
238 |               },
239 |               name: {
240 |                 type: 'string'
241 |               },
242 |               species: {
243 |                 type: 'string'
244 |               },
245 |               age: {
246 |                 type: 'integer'
247 |               },
248 |               status: {
249 |                 type: 'string',
250 |                 enum: ['available', 'pending', 'sold']
251 |               }
252 |             }
253 |           },
254 |           NewPet: {
255 |             type: 'object',
256 |             required: ['name', 'species'],
257 |             properties: {
258 |               name: {
259 |                 type: 'string'
260 |               },
261 |               species: {
262 |                 type: 'string'
263 |               },
264 |               age: {
265 |                 type: 'integer'
266 |               }
267 |             }
268 |           },
269 |           PetUpdate: {
270 |             type: 'object',
271 |             properties: {
272 |               name: {
273 |                 type: 'string'
274 |               },
275 |               species: {
276 |                 type: 'string'
277 |               },
278 |               age: {
279 |                 type: 'integer'
280 |               },
281 |               status: {
282 |                 type: 'string',
283 |                 enum: ['available', 'pending', 'sold']
284 |               }
285 |             }
286 |           },
287 |           Error: {
288 |             type: 'object',
289 |             required: ['code', 'message'],
290 |             properties: {
291 |               code: {
292 |                 type: 'string'
293 |               },
294 |               message: {
295 |                 type: 'string'
296 |               }
297 |             }
298 |           }
299 |         }
300 |       }
301 |     })
302 |   })
303 | 
304 |   // 모든 펫 목록 조회
305 |   app.get('/pets', (req, res) => {
306 |     let result = [...pets]
307 |     
308 |     // 상태별 필터링
309 |     if (req.query.status) {
310 |       result = result.filter(pet => pet.status === req.query.status)
311 |     }
312 |     
313 |     res.json(result)
314 |   })
315 | 
316 |   // 특정 펫 조회
317 |   app.get('/pets/:id', (req, res) => {
318 |     const id = parseInt(req.params.id)
319 |     const pet = pets.find(p => p.id === id)
320 |     
321 |     if (!pet) {
322 |       return res.status(404).json({
323 |         code: 'RESOURCE_NOT_FOUND',
324 |         message: 'Pet not found',
325 |         petId: id
326 |       })
327 |     }
328 |     
329 |     res.json(pet)
330 |   })
331 | 
332 |   // 펫 생성
333 |   app.post('/pets', (req, res) => {
334 |     const { name, species, age } = req.body
335 |     
336 |     if (!name || !species) {
337 |       return res.status(400).json({
338 |         code: 'VALIDATION_ERROR',
339 |         message: 'Name and species are required'
340 |       })
341 |     }
342 |     
343 |     const newPet = {
344 |       id: nextId++,
345 |       name,
346 |       species,
347 |       age: age || 0,
348 |       status: 'available'
349 |     }
350 |     
351 |     pets.push(newPet)
352 |     res.status(201).json(newPet)
353 |   })
354 | 
355 |   // 펫 정보 업데이트
356 |   app.put('/pets/:id', (req, res) => {
357 |     const id = parseInt(req.params.id)
358 |     const petIndex = pets.findIndex(p => p.id === id)
359 |     
360 |     if (petIndex === -1) {
361 |       return res.status(404).json({
362 |         code: 'RESOURCE_NOT_FOUND',
363 |         message: 'Pet not found',
364 |         petId: id
365 |       })
366 |     }
367 |     
368 |     const { name, species, age, status } = req.body
369 |     const updatedPet = {
370 |       ...pets[petIndex],
371 |       name: name !== undefined ? name : pets[petIndex].name,
372 |       species: species !== undefined ? species : pets[petIndex].species,
373 |       age: age !== undefined ? age : pets[petIndex].age,
374 |       status: status !== undefined ? status : pets[petIndex].status
375 |     }
376 |     
377 |     pets[petIndex] = updatedPet
378 |     res.json(updatedPet)
379 |   })
380 | 
381 |   // 펫 삭제
382 |   app.delete('/pets/:id', (req, res) => {
383 |     const id = parseInt(req.params.id)
384 |     const petIndex = pets.findIndex(p => p.id === id)
385 |     
386 |     if (petIndex === -1) {
387 |       return res.status(404).json({
388 |         code: 'RESOURCE_NOT_FOUND',
389 |         message: 'Pet not found',
390 |         petId: id
391 |       })
392 |     }
393 |     
394 |     pets.splice(petIndex, 1)
395 |     res.status(204).end()
396 |   })
397 | 
398 |   // 서버 시작
399 |   const server = app.listen(port, () => {
400 |     console.log(`Petstore server running on http://localhost:${port}`)
401 |   })
402 | 
403 |   return server
404 | }
405 | 
406 | module.exports = { createPetstoreServer } 
```

--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/__tests__/http-client.test.ts:
--------------------------------------------------------------------------------

```typescript
  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: '<html><body><h1>500 Internal Server Error</h1></body></html>',
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: '<html><body><h1>500 Internal Server Error</h1></body></html>',
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/openapi/parser.ts:
--------------------------------------------------------------------------------

```typescript
  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<string, unknown>
 16 |   required?: string[]
 17 |   [key: string]: unknown
 18 | }
 19 | 
 20 | export class OpenAPIToMCPConverter {
 21 |   private schemaCache: Record<string, IJsonSchema> = {}
 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<string>): 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<string>,
 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<string, { methods: NewToolMethod[] }>
168 |     openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
169 |     zip: Record<string, { openApi: OpenAPIV3.OperationObject & { method: string; path: string }; mcp: NewToolMethod }>
170 |   } {
171 |     const apiName = 'API'
172 | 
173 |     const openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }> = {}
174 |     const tools: Record<string, { methods: NewToolMethod[] }> = {
175 |       [apiName]: { methods: [] },
176 |     }
177 |     const zip: Record<string, { openApi: OpenAPIV3.OperationObject & { method: string; path: string }; mcp: NewToolMethod }> = {}
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<string, IJsonSchema> {
252 |     const components = this.openApiSpec.components || {}
253 |     const schema: Record<string, IJsonSchema> = {}
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 | 
```

--------------------------------------------------------------------------------
/scripts/notion-openapi.json:
--------------------------------------------------------------------------------

```json
  1 | {
  2 |   "openapi": "3.1.0",
  3 |   "info": {
  4 |     "title": "Notion API",
  5 |     "version": "1"
  6 |   },
  7 |   "servers": [
  8 |     {
  9 |       "url": "https://api.notion.com"
 10 |     }
 11 |   ],
 12 |   "components": {
 13 |     "securitySchemes": {
 14 |       "bearerAuth": {
 15 |         "type": "http",
 16 |         "scheme": "bearer"
 17 |       },
 18 |       "basicAuth": {
 19 |         "type": "http",
 20 |         "scheme": "basic"
 21 |       }
 22 |     },
 23 |     "parameters": {},
 24 |     "schemas": {}
 25 |   },
 26 |   "security": [
 27 |     {
 28 |       "bearerAuth": []
 29 |     }
 30 |   ],
 31 |   "paths": {
 32 |     "/v1/blocks/{block_id}/children": {
 33 |       "get": {
 34 |         "summary": "Retrieve block children",
 35 |         "description": "",
 36 |         "operationId": "get-block-children",
 37 |         "parameters": [
 38 |           {
 39 |             "name": "block_id",
 40 |             "in": "path",
 41 |             "description": "Identifier for a [block](ref:block)",
 42 |             "schema": {
 43 |               "type": "string"
 44 |             },
 45 |             "required": true
 46 |           },
 47 |           {
 48 |             "name": "start_cursor",
 49 |             "in": "query",
 50 |             "description": "If supplied, this endpoint will return a page of results starting after the cursor provided. If not supplied, this endpoint will return the first page of results.",
 51 |             "schema": {
 52 |               "type": "string"
 53 |             }
 54 |           },
 55 |           {
 56 |             "name": "page_size",
 57 |             "in": "query",
 58 |             "description": "The number of items from the full list desired in the response. Maximum: 100",
 59 |             "schema": {
 60 |               "type": "integer",
 61 |               "format": "int32",
 62 |               "default": 100
 63 |             }
 64 |           }
 65 |         ],
 66 |         "responses": {},
 67 |         "deprecated": false,
 68 |         "security": []
 69 |       }
 70 |     },
 71 |     "/v1/pages/{page_id}": {
 72 |       "get": {
 73 |         "summary": "Retrieve a page",
 74 |         "description": "",
 75 |         "operationId": "retrieve-a-page",
 76 |         "parameters": [
 77 |           {
 78 |             "name": "page_id",
 79 |             "in": "path",
 80 |             "description": "Identifier for a Notion page",
 81 |             "schema": {
 82 |               "type": "string"
 83 |             },
 84 |             "required": true
 85 |           },
 86 |           {
 87 |             "name": "filter_properties",
 88 |             "in": "query",
 89 |             "description": "A list of page property value IDs associated with the page. Use this param to limit the response to a specific page property value or values. To retrieve multiple properties, specify each page property ID. For example: `?filter_properties=iAk8&filter_properties=b7dh`.",
 90 |             "schema": {
 91 |               "type": "string"
 92 |             }
 93 |           }
 94 |         ],
 95 |         "responses": {},
 96 |         "deprecated": false,
 97 |         "security": []
 98 |       }
 99 |     },
100 |     "/v1/blocks/{block_id}": {
101 |       "get": {
102 |         "summary": "Retrieve a block",
103 |         "description": "",
104 |         "operationId": "retrieve-a-block",
105 |         "parameters": [
106 |           {
107 |             "name": "block_id",
108 |             "in": "path",
109 |             "description": "Identifier for a Notion block",
110 |             "schema": {
111 |               "type": "string"
112 |             },
113 |             "required": true
114 |           }
115 |         ],
116 |         "responses": {},
117 |         "deprecated": false,
118 |         "security": []
119 |       }
120 |     },
121 |     "/v1/databases/{database_id}": {
122 |       "get": {
123 |         "summary": "Retrieve a database",
124 |         "description": "",
125 |         "operationId": "retrieve-a-database",
126 |         "parameters": [
127 |           {
128 |             "name": "database_id",
129 |             "in": "path",
130 |             "description": "An identifier for the Notion database.",
131 |             "schema": {
132 |               "type": "string"
133 |             },
134 |             "required": true
135 |           }
136 |         ],
137 |         "responses": {
138 |           "200": {
139 |             "description": "200",
140 |             "content": {
141 |               "application/json": {
142 |                 "examples": {
143 |                   "Result": {
144 |                     "value": "{\n    \"object\": \"database\",\n    \"id\": \"bc1211ca-e3f1-4939-ae34-5260b16f627c\",\n    \"created_time\": \"2021-07-08T23:50:00.000Z\",\n    \"last_edited_time\": \"2021-07-08T23:50:00.000Z\",\n    \"icon\": {\n        \"type\": \"emoji\",\n        \"emoji\": \"🎉\"\n    },\n    \"cover\": {\n        \"type\": \"external\",\n        \"external\": {\n            \"url\": \"https://website.domain/images/image.png\"\n        }\n    },\n    \"url\": \"https://www.notion.so/bc1211cae3f14939ae34260b16f627c\",\n    \"title\": [\n        {\n            \"type\": \"text\",\n            \"text\": {\n                \"content\": \"Grocery List\",\n                \"link\": null\n            },\n            \"annotations\": {\n                \"bold\": false,\n                \"italic\": false,\n                \"strikethrough\": false,\n                \"underline\": false,\n                \"code\": false,\n                \"color\": \"default\"\n            },\n            \"plain_text\": \"Grocery List\",\n            \"href\": null\n        }\n    ],\n    \"description\": [\n        {\n            \"type\": \"text\",\n            \"text\": {\n                \"content\": \"Grocery list for just kale 🥬\",\n                \"link\": null\n            },\n            \"annotations\": {\n                \"bold\": false,\n                \"italic\": false,\n                \"strikethrough\": false,\n                \"underline\": false,\n                \"code\": false,\n                \"color\": \"default\"\n            },\n            \"plain_text\": \"Grocery list for just kale 🥬\",\n            \"href\": null\n        }\n    ],\n    \"properties\": {\n        \"+1\": {\n            \"id\": \"Wp%3DC\",\n            \"name\": \"+1\",\n            \"type\": \"people\",\n            \"people\": {}\n        },\n        \"In stock\": {\n            \"id\": \"fk%5EY\",\n            \"name\": \"In stock\",\n            \"type\": \"checkbox\",\n            \"checkbox\": {}\n        },\n        \"Price\": {\n            \"id\": \"evWq\",\n            \"name\": \"Price\",\n            \"type\": \"number\",\n            \"number\": {\n                \"format\": \"dollar\"\n            }\n        },\n        \"Description\": {\n            \"id\": \"V}lX\",\n            \"name\": \"Description\",\n            \"type\": \"rich_text\",\n            \"rich_text\": {}\n        },\n        \"Last ordered\": {\n            \"id\": \"eVnV\",\n            \"name\": \"Last ordered\",\n            \"type\": \"date\",\n            \"date\": {}\n        },\n        \"Meals\": {\n            \"id\": \"%7DWA~\",\n            \"name\": \"Meals\",\n            \"type\": \"relation\",\n            \"relation\": {\n                \"database_id\": \"668d797c-76fa-4934-9b05-ad288df2d136\",\n                \"synced_property_name\": \"Related to Grocery List (Meals)\"\n            }\n        },\n        \"Number of meals\": {\n            \"id\": \"Z\\\\Eh\",\n            \"name\": \"Number of meals\",\n            \"type\": \"rollup\",\n            \"rollup\": {\n                \"rollup_property_name\": \"Name\",\n                \"relation_property_name\": \"Meals\",\n                \"rollup_property_id\": \"title\",\n                \"relation_property_id\": \"mxp^\",\n                \"function\": \"count\"\n            }\n        },\n        \"Store availability\": {\n            \"id\": \"s}Kq\",\n            \"name\": \"Store availability\",\n            \"type\": \"multi_select\",\n            \"multi_select\": {\n                \"options\": [\n                    {\n                        \"id\": \"cb79b393-d1c1-4528-b517-c450859de766\",\n                        \"name\": \"Duc Loi Market\",\n                        \"color\": \"blue\"\n                    },\n                    {\n                        \"id\": \"58aae162-75d4-403b-a793-3bc7308e4cd2\",\n                        \"name\": \"Rainbow Grocery\",\n                        \"color\": \"gray\"\n                    },\n                    {\n                        \"id\": \"22d0f199-babc-44ff-bd80-a9eae3e3fcbf\",\n                        \"name\": \"Nijiya Market\",\n                        \"color\": \"purple\"\n                    },\n                    {\n                        \"id\": \"0d069987-ffb0-4347-bde2-8e4068003dbc\",\n                        \"name\": \"Gus's Community Market\",\n                        \"color\": \"yellow\"\n                    }\n                ]\n            }\n        },\n        \"Photo\": {\n            \"id\": \"yfiK\",\n            \"name\": \"Photo\",\n            \"type\": \"files\",\n            \"files\": {}\n        },\n        \"Food group\": {\n            \"id\": \"CM%3EH\",\n            \"name\": \"Food group\",\n            \"type\": \"select\",\n            \"select\": {\n                \"options\": [\n                    {\n                        \"id\": \"6d4523fa-88cb-4ffd-9364-1e39d0f4e566\",\n                        \"name\": \"🥦Vegetable\",\n                        \"color\": \"green\"\n                    },\n                    {\n                        \"id\": \"268d7e75-de8f-4c4b-8b9d-de0f97021833\",\n                        \"name\": \"🍎Fruit\",\n                        \"color\": \"red\"\n                    },\n                    {\n                        \"id\": \"1b234a00-dc97-489c-b987-829264cfdfef\",\n                        \"name\": \"💪Protein\",\n                        \"color\": \"yellow\"\n                    }\n                ]\n            }\n        },\n        \"Name\": {\n            \"id\": \"title\",\n            \"name\": \"Name\",\n            \"type\": \"title\",\n            \"title\": {}\n        }\n    },\n    \"parent\": {\n        \"type\": \"page_id\",\n        \"page_id\": \"98ad959b-2b6a-4774-80ee-00246fb0ea9b\"\n    },\n    \"archived\": false,\n    \"is_inline\": false,\n    \"public_url\": null\n}"
145 |                   }
146 |                 }
147 |               }
148 |             }
149 |           }
150 |         },
151 |         "deprecated": false,
152 |         "security": []
153 |       }
154 |     },
155 |     "/v1/comments": {
156 |       "get": {
157 |         "summary": "Retrieve comments",
158 |         "description": "Retrieves a list of un-resolved [Comment objects](ref:comment-object) from a page or block.",
159 |         "operationId": "retrieve-a-comment",
160 |         "parameters": [
161 |           {
162 |             "name": "block_id",
163 |             "in": "query",
164 |             "description": "Identifier for a Notion block or page",
165 |             "required": true,
166 |             "schema": {
167 |               "type": "string"
168 |             }
169 |           },
170 |           {
171 |             "name": "start_cursor",
172 |             "in": "query",
173 |             "description": "If supplied, this endpoint will return a page of results starting after the cursor provided. If not supplied, this endpoint will return the first page of results.",
174 |             "schema": {
175 |               "type": "string"
176 |             }
177 |           },
178 |           {
179 |             "name": "page_size",
180 |             "in": "query",
181 |             "description": "The number of items from the full list desired in the response. Maximum: 100",
182 |             "schema": {
183 |               "type": "integer",
184 |               "format": "int32"
185 |             }
186 |           }
187 |         ],
188 |         "responses": {
189 |           "200": {
190 |             "description": "200",
191 |             "content": {
192 |               "application/json": {
193 |                 "examples": {
194 |                   "OK": {
195 |                     "value": "{\n    \"object\": \"list\",\n    \"results\": [\n        {\n            \"object\": \"comment\",\n            \"id\": \"94cc56ab-9f02-409d-9f99-1037e9fe502f\",\n            \"parent\": {\n                \"type\": \"page_id\",\n                \"page_id\": \"5c6a2821-6bb1-4a7e-b6e1-c50111515c3d\"\n            },\n            \"discussion_id\": \"f1407351-36f5-4c49-a13c-49f8ba11776d\",\n            \"created_time\": \"2022-07-15T16:52:00.000Z\",\n            \"last_edited_time\": \"2022-07-15T19:16:00.000Z\",\n            \"created_by\": {\n                \"object\": \"user\",\n                \"id\": \"9b15170a-9941-4297-8ee6-83fa7649a87a\"\n            },\n            \"rich_text\": [\n                {\n                    \"type\": \"text\",\n                    \"text\": {\n                        \"content\": \"Single comment\",\n                        \"link\": null\n                    },\n                    \"annotations\": {\n                        \"bold\": false,\n                        \"italic\": false,\n                        \"strikethrough\": false,\n                        \"underline\": false,\n                        \"code\": false,\n                        \"color\": \"default\"\n                    },\n                    \"plain_text\": \"Single comment\",\n                    \"href\": null\n                }\n            ]\n        }\n    ],\n    \"next_cursor\": null,\n    \"has_more\": false,\n    \"type\": \"comment\",\n    \"comment\": {}\n}"
196 |                   }
197 |                 }
198 |               }
199 |             }
200 |           }
201 |         },
202 |         "deprecated": false,
203 |         "security": []
204 |       }
205 |     },
206 |     "/v1/pages/{page_id}/properties/{property_id}": {
207 |       "get": {
208 |         "summary": "Retrieve a page property item",
209 |         "description": "",
210 |         "operationId": "retrieve-a-page-property",
211 |         "parameters": [
212 |           {
213 |             "name": "page_id",
214 |             "in": "path",
215 |             "description": "Identifier for a Notion page",
216 |             "schema": {
217 |               "type": "string"
218 |             },
219 |             "required": true
220 |           },
221 |           {
222 |             "name": "property_id",
223 |             "in": "path",
224 |             "description": "Identifier for a page [property](https://developers.notion.com/reference/page#all-property-values)",
225 |             "schema": {
226 |               "type": "string"
227 |             },
228 |             "required": true
229 |           },
230 |           {
231 |             "name": "page_size",
232 |             "in": "query",
233 |             "description": "For paginated properties. The max number of property item objects on a page. The default size is 100",
234 |             "schema": {
235 |               "type": "integer",
236 |               "format": "int32"
237 |             }
238 |           },
239 |           {
240 |             "name": "start_cursor",
241 |             "in": "query",
242 |             "description": "For paginated properties.",
243 |             "schema": {
244 |               "type": "string"
245 |             }
246 |           }
247 |         ],
248 |         "responses": {
249 |           "200": {
250 |             "description": "200",
251 |             "content": {
252 |               "application/json": {
253 |                 "examples": {
254 |                   "Number Property Item": {
255 |                     "value": "{\n  \"object\": \"property_item\",\n  \"id\" \"kjPO\",\n  \"type\": \"number\",\n  \"number\": 2\n}"
256 |                   },
257 |                   "Result": {
258 |                     "value": "{\n    \"object\": \"list\",\n    \"results\": [\n        {\n            \"object\": \"property_item\",\n            \"id\" \"kjPO\",\n            \"type\": \"rich_text\",\n            \"rich_text\": {\n                \"type\": \"text\",\n                \"text\": {\n                    \"content\": \"Avocado \",\n                    \"link\": null\n                },\n                \"annotations\": {\n                    \"bold\": false,\n                    \"italic\": false,\n                    \"strikethrough\": false,\n                    \"underline\": false,\n                    \"code\": false,\n                    \"color\": \"default\"\n                },\n                \"plain_text\": \"Avocado \",\n                \"href\": null\n            }\n        },\n        {\n            \"object\": \"property_item\",\n            \"id\" \"ijPO\",\n            \"type\": \"rich_text\",\n            \"rich_text\": {\n                \"type\": \"mention\",\n                \"mention\": {\n                    \"type\": \"page\",\n                    \"page\": {\n                        \"id\": \"41117fd7-69a5-4694-bc07-c1e3a682c857\"\n                    }\n                },\n                \"annotations\": {\n                    \"bold\": false,\n                    \"italic\": false,\n                    \"strikethrough\": false,\n                    \"underline\": false,\n                    \"code\": false,\n                    \"color\": \"default\"\n                },\n                \"plain_text\": \"Lemons\",\n                \"href\": \"http://notion.so/41117fd769a54694bc07c1e3a682c857\"\n            }\n        },\n        {\n            \"object\": \"property_item\",\n            \"id\" \"kjPO\",\n            \"type\": \"rich_text\",\n            \"rich_text\": {\n                \"type\": \"text\",\n                \"text\": {\n                    \"content\": \" Tomato \",\n                    \"link\": null\n                },\n                \"annotations\": {\n                    \"bold\": false,\n                    \"italic\": false,\n                    \"strikethrough\": false,\n                    \"underline\": false,\n                    \"code\": false,\n                    \"color\": \"default\"\n                },\n                \"plain_text\": \" Tomato \",\n                \"href\": null\n            }\n        },\n...\n    ],\n    \"next_cursor\": \"some-next-cursor-value\",\n    \"has_more\": true,\n\t\t\"next_url\": \"http://api.notion.com/v1/pages/0e5235bf86aa4efb93aa772cce7eab71/properties/NVv^?start_cursor=some-next-cursor-value&page_size=25\",\n    \"property_item\": {\n      \"id\": \"NVv^\",\n      \"next_url\": null,\n      \"type\": \"rich_text\",\n      \"rich_text\": {}\n    }\n}"
259 |                   },
260 |                   "Rollup List Property Item": {
261 |                     "value": "{\n    \"object\": \"list\",\n    \"results\": [\n        {\n            \"object\": \"property_item\",\n          \t\"id\": \"dj2l\",\n            \"type\": \"relation\",\n            \"relation\": {\n                \"id\": \"83f92c9d-523d-466e-8c1f-9bc2c25a99fe\"\n            }\n        },\n        {\n            \"object\": \"property_item\",\n          \t\"id\": \"dj2l\",\n            \"type\": \"relation\",\n            \"relation\": {\n                \"id\": \"45cfb825-3463-4891-8932-7e6d8c170630\"\n            }\n        },\n        {\n            \"object\": \"property_item\",\n          \t\"id\": \"dj2l\",\n            \"type\": \"relation\",\n            \"relation\": {\n                \"id\": \"1688be1a-a197-4f2a-9688-e528c4b56d94\"\n            }\n        }\n    ],\n    \"next_cursor\": \"some-next-cursor-value\",\n    \"has_more\": true,\n\t\t\"property_item\": {\n      \"id\": \"y}~p\",\n      \"next_url\": \"http://api.notion.com/v1/pages/0e5235bf86aa4efb93aa772cce7eab71/properties/y%7D~p?start_cursor=1QaTunT5&page_size=25\",\n      \"type\": \"rollup\",\n      \"rollup\": {\n        \"function\": \"sum\",\n        \"type\": \"incomplete\",\n        \"incomplete\": {}\n      }\n    }\n    \"type\": \"property_item\"\n}"
262 |                   }
263 |                 }
264 |               }
265 |             }
266 |           }
267 |         },
268 |         "deprecated": false,
269 |         "security": []
270 |       }
271 |     }
272 |   }
273 | }
274 | 
```

--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts:
--------------------------------------------------------------------------------

```typescript
  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 | 
```
Page 1/2FirstPrevNextLast