#
tokens: 37083/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/makenotion/notion-mcp-server?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .dockerignore
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── docs
│   └── images
│       ├── connections.png
│       ├── integration-access.png
│       ├── integrations-capabilities.png
│       ├── integrations-creation.png
│       └── page-access-edit.png
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── scripts
│   ├── build-cli.js
│   ├── notion-openapi.json
│   └── start-server.ts
├── smithery.yaml
├── 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
│       │   └── polyfill-headers.ts
│       ├── index.ts
│       ├── mcp
│       │   ├── __tests__
│       │   │   └── proxy.test.ts
│       │   └── proxy.ts
│       ├── openapi
│       │   ├── __tests__
│       │   │   ├── file-upload.test.ts
│       │   │   ├── parser-multipart.test.ts
│       │   │   └── parser.test.ts
│       │   ├── file-upload.ts
│       │   └── parser.ts
│       └── 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 | 
```

--------------------------------------------------------------------------------
/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 MCP Server
  2 | 
  3 | > [!NOTE] 
  4 | > 
  5 | > We’ve introduced **Notion MCP**, a remote MCP server with the following improvements:
  6 | > - Easy installation via standard OAuth. No need to fiddle with JSON or API token anymore.
  7 | > - Powerful tools tailored to AI agents. These tools are designed with optimized token consumption in mind.
  8 | > 
  9 | > Learn more and try it out [here](https://developers.notion.com/docs/mcp)
 10 | 
 11 | 
 12 | ![notion-mcp-sm](https://github.com/user-attachments/assets/6c07003c-8455-4636-b298-d60ffdf46cd8)
 13 | 
 14 | This project implements an [MCP server](https://spec.modelcontextprotocol.io/) for the [Notion API](https://developers.notion.com/reference/intro). 
 15 | 
 16 | ![mcp-demo](https://github.com/user-attachments/assets/e3ff90a7-7801-48a9-b807-f7dd47f0d3d6)
 17 | 
 18 | ### Installation
 19 | 
 20 | #### 1. Setting up Integration in Notion:
 21 | Go to [https://www.notion.so/profile/integrations](https://www.notion.so/profile/integrations) and create a new **internal** integration or select an existing one.
 22 | 
 23 | ![Creating a Notion Integration token](docs/images/integrations-creation.png)
 24 | 
 25 | While we limit the scope of Notion API's exposed (for example, you will not be able to delete databases via MCP), there is a non-zero risk to workspace data by exposing it to LLMs. Security-conscious users may want to further configure the Integration's _Capabilities_. 
 26 | 
 27 | For example, you can create a read-only integration token by giving only "Read content" access from the "Configuration" tab:
 28 | 
 29 | ![Notion Integration Token Capabilities showing Read content checked](docs/images/integrations-capabilities.png)
 30 | 
 31 | #### 2. Connecting content to integration:
 32 | Ensure relevant pages and databases are connected to your integration.
 33 | 
 34 | To do this, visit the **Access** tab in your internal integration settings. Edit access and select the pages you'd like to use.
 35 | ![Integration Access tab](docs/images/integration-access.png)
 36 | 
 37 | ![Edit integration access](docs/images/page-access-edit.png)
 38 | 
 39 | Alternatively, you can grant page access individually. You'll need to visit the target page, and click on the 3 dots, and select "Connect to integration". 
 40 | 
 41 | ![Adding Integration Token to Notion Connections](docs/images/connections.png)
 42 | 
 43 | #### 3. Adding MCP config to your client:
 44 | 
 45 | ##### Using npm:
 46 | 
 47 | **Cursor & Claude:**
 48 | 
 49 | Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json` (MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json`)
 50 | 
 51 | **Option 1: Using NOTION_TOKEN (recommended)**
 52 | ```javascript
 53 | {
 54 |   "mcpServers": {
 55 |     "notionApi": {
 56 |       "command": "npx",
 57 |       "args": ["-y", "@notionhq/notion-mcp-server"],
 58 |       "env": {
 59 |         "NOTION_TOKEN": "ntn_****"
 60 |       }
 61 |     }
 62 |   }
 63 | }
 64 | ```
 65 | 
 66 | **Option 2: Using OPENAPI_MCP_HEADERS (for advanced use cases)**
 67 | ```javascript
 68 | {
 69 |   "mcpServers": {
 70 |     "notionApi": {
 71 |       "command": "npx",
 72 |       "args": ["-y", "@notionhq/notion-mcp-server"],
 73 |       "env": {
 74 |         "OPENAPI_MCP_HEADERS": "{\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\" }"
 75 |       }
 76 |     }
 77 |   }
 78 | }
 79 | ```
 80 | 
 81 | **Zed**
 82 | 
 83 | Add the following to your `settings.json`
 84 | 
 85 | ```json
 86 | {
 87 |   "context_servers": {
 88 |     "some-context-server": {
 89 |       "command": {
 90 |         "path": "npx",
 91 |         "args": ["-y", "@notionhq/notion-mcp-server"],
 92 |         "env": {
 93 |           "OPENAPI_MCP_HEADERS": "{\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\" }"
 94 |         }
 95 |       },
 96 |       "settings": {}
 97 |     }
 98 |   }
 99 | }
100 | ```
101 | 
102 | ##### Using Docker:
103 | 
104 | There are two options for running the MCP server with Docker:
105 | 
106 | ###### Option 1: Using the official Docker Hub image:
107 | 
108 | Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json`:
109 | 
110 | **Using NOTION_TOKEN (recommended):**
111 | ```javascript
112 | {
113 |   "mcpServers": {
114 |     "notionApi": {
115 |       "command": "docker",
116 |       "args": [
117 |         "run",
118 |         "--rm",
119 |         "-i",
120 |         "-e", "NOTION_TOKEN",
121 |         "mcp/notion"
122 |       ],
123 |       "env": {
124 |         "NOTION_TOKEN": "ntn_****"
125 |       }
126 |     }
127 |   }
128 | }
129 | ```
130 | 
131 | **Using OPENAPI_MCP_HEADERS (for advanced use cases):**
132 | ```javascript
133 | {
134 |   "mcpServers": {
135 |     "notionApi": {
136 |       "command": "docker",
137 |       "args": [
138 |         "run",
139 |         "--rm",
140 |         "-i",
141 |         "-e", "OPENAPI_MCP_HEADERS",
142 |         "mcp/notion"
143 |       ],
144 |       "env": {
145 |         "OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer ntn_****\",\"Notion-Version\":\"2022-06-28\"}"
146 |       }
147 |     }
148 |   }
149 | }
150 | ```
151 | 
152 | This approach:
153 | - Uses the official Docker Hub image
154 | - Properly handles JSON escaping via environment variables
155 | - Provides a more reliable configuration method
156 | 
157 | ###### Option 2: Building the Docker image locally:
158 | 
159 | You can also build and run the Docker image locally. First, build the Docker image:
160 | 
161 | ```bash
162 | docker compose build
163 | ```
164 | 
165 | Then, add the following to your `.cursor/mcp.json` or `claude_desktop_config.json`:
166 | 
167 | **Using NOTION_TOKEN (recommended):**
168 | ```javascript
169 | {
170 |   "mcpServers": {
171 |     "notionApi": {
172 |       "command": "docker",
173 |       "args": [
174 |         "run",
175 |         "--rm",
176 |         "-i",
177 |         "-e",
178 |         "NOTION_TOKEN=ntn_****",
179 |         "notion-mcp-server"
180 |       ]
181 |     }
182 |   }
183 | }
184 | ```
185 | 
186 | **Using OPENAPI_MCP_HEADERS (for advanced use cases):**
187 | ```javascript
188 | {
189 |   "mcpServers": {
190 |     "notionApi": {
191 |       "command": "docker",
192 |       "args": [
193 |         "run",
194 |         "--rm",
195 |         "-i",
196 |         "-e",
197 |         "OPENAPI_MCP_HEADERS={\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\"}",
198 |         "notion-mcp-server"
199 |       ]
200 |     }
201 |   }
202 | }
203 | ```
204 | 
205 | Don't forget to replace `ntn_****` with your integration secret. Find it from your integration configuration tab:
206 | 
207 | ![Copying your Integration token from the Configuration tab in the developer portal](https://github.com/user-attachments/assets/67b44536-5333-49fa-809c-59581bf5370a)
208 | 
209 | 
210 | #### Installing via Smithery
211 | 
212 | [![smithery badge](https://smithery.ai/badge/@makenotion/notion-mcp-server)](https://smithery.ai/server/@makenotion/notion-mcp-server)
213 | 
214 | To install Notion API Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@makenotion/notion-mcp-server):
215 | 
216 | ```bash
217 | npx -y @smithery/cli install @makenotion/notion-mcp-server --client claude
218 | ```
219 | 
220 | ### Transport Options
221 | 
222 | The Notion MCP Server supports two transport modes:
223 | 
224 | #### STDIO Transport (Default)
225 | The default transport mode uses standard input/output for communication. This is the standard MCP transport used by most clients like Claude Desktop.
226 | 
227 | ```bash
228 | # Run with default stdio transport
229 | npx @notionhq/notion-mcp-server
230 | 
231 | # Or explicitly specify stdio
232 | npx @notionhq/notion-mcp-server --transport stdio
233 | ```
234 | 
235 | #### Streamable HTTP Transport
236 | For web-based applications or clients that prefer HTTP communication, you can use the Streamable HTTP transport:
237 | 
238 | ```bash
239 | # Run with Streamable HTTP transport on port 3000 (default)
240 | npx @notionhq/notion-mcp-server --transport http
241 | 
242 | # Run on a custom port
243 | npx @notionhq/notion-mcp-server --transport http --port 8080
244 | 
245 | # Run with a custom authentication token
246 | npx @notionhq/notion-mcp-server --transport http --auth-token "your-secret-token"
247 | ```
248 | 
249 | When using Streamable HTTP transport, the server will be available at `http://0.0.0.0:<port>/mcp`.
250 | 
251 | ##### Authentication
252 | The Streamable HTTP transport requires bearer token authentication for security. You have three options:
253 | 
254 | **Option 1: Auto-generated token (recommended for development)**
255 | ```bash
256 | npx @notionhq/notion-mcp-server --transport http
257 | ```
258 | The server will generate a secure random token and display it in the console:
259 | ```
260 | Generated auth token: a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789ab
261 | Use this token in the Authorization header: Bearer a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789ab
262 | ```
263 | 
264 | **Option 2: Custom token via command line (recommended for production)**
265 | ```bash
266 | npx @notionhq/notion-mcp-server --transport http --auth-token "your-secret-token"
267 | ```
268 | 
269 | **Option 3: Custom token via environment variable (recommended for production)**
270 | ```bash
271 | AUTH_TOKEN="your-secret-token" npx @notionhq/notion-mcp-server --transport http
272 | ```
273 | 
274 | The command line argument `--auth-token` takes precedence over the `AUTH_TOKEN` environment variable if both are provided.
275 | 
276 | ##### Making HTTP Requests
277 | All requests to the Streamable HTTP transport must include the bearer token in the Authorization header:
278 | 
279 | ```bash
280 | # Example request
281 | curl -H "Authorization: Bearer your-token-here" \
282 |      -H "Content-Type: application/json" \
283 |      -H "mcp-session-id: your-session-id" \
284 |      -d '{"jsonrpc": "2.0", "method": "initialize", "params": {}, "id": 1}' \
285 |      http://localhost:3000/mcp
286 | ```
287 | 
288 | **Note:** Make sure to set either the `NOTION_TOKEN` environment variable (recommended) or the `OPENAPI_MCP_HEADERS` environment variable with your Notion integration token when using either transport mode.
289 | 
290 | ### Examples
291 | 
292 | 1. Using the following instruction
293 | ```
294 | Comment "Hello MCP" on page "Getting started"
295 | ```
296 | 
297 | AI will correctly plan two API calls, `v1/search` and `v1/comments`, to achieve the task
298 | 
299 | 2. Similarly, the following instruction will result in a new page named "Notion MCP" added to parent page "Development"
300 | ```
301 | Add a page titled "Notion MCP" to page "Development"
302 | ```
303 | 
304 | 3. You may also reference content ID directly
305 | ```
306 | Get the content of page 1a6b35e6e67f802fa7e1d27686f017f2
307 | ```
308 | 
309 | ### Development
310 | 
311 | Build
312 | 
313 | ```
314 | npm run build
315 | ```
316 | 
317 | Execute
318 | 
319 | ```
320 | npx -y --prefix /path/to/local/notion-mcp-server @notionhq/notion-mcp-server
321 | ```
322 | 
323 | Publish
324 | 
325 | ```
326 | npm publish --access public
327 | ```
328 | 
```

--------------------------------------------------------------------------------
/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"]
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 | 
```

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

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

--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/polyfill-headers.ts:
--------------------------------------------------------------------------------

```typescript
 1 | /*
 2 | * The Headers class was supported in Node.js starting with version 18, which was released on April 19, 2022.
 3 | * We need to have a polyfill ready to work for old Node versions.
 4 | * See more at https://github.com/makenotion/notion-mcp-server/issues/32
 5 | * */
 6 | class PolyfillHeaders {
 7 |   private headers: Map<string, string[]> = new Map();
 8 | 
 9 |   constructor(init?: Record<string, string>) {
10 |     if (init) {
11 |       Object.entries(init).forEach(([key, value]) => {
12 |         this.append(key, value);
13 |       });
14 |     }
15 |   }
16 | 
17 |   public append(name: string, value: string): void {
18 |     const key = name.toLowerCase();
19 | 
20 |     if (!this.headers.has(key)) {
21 |       this.headers.set(key, []);
22 |     }
23 | 
24 |     this.headers.get(key)!.push(value);
25 |   }
26 | 
27 |   public get(name: string): string | null {
28 |     const key = name.toLowerCase();
29 | 
30 |     if (!this.headers.has(key)) {
31 |       return null;
32 |     }
33 | 
34 |     return this.headers.get(key)!.join(', ');
35 |   }
36 | }
37 | 
38 | const GlobalHeaders = typeof global !== 'undefined' && 'Headers' in global
39 |   ? (global as any).Headers
40 |   : undefined;
41 | 
42 | export const Headers = (GlobalHeaders || PolyfillHeaders);
43 | 
```

--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------

```yaml
 1 | # Smithery configuration file: https://smithery.ai/docs/build/project-config
 2 | 
 3 | startCommand:
 4 |   type: stdio
 5 |   commandFunction:
 6 |     # A JS function that produces the CLI command based on the given config to start the MCP on stdio.
 7 |     |-
 8 |     (config) => {
 9 |       const env = {};
10 |       if (config.notionToken) {
11 |         env.NOTION_TOKEN = config.notionToken;
12 |       } else if (config.openapiMcpHeaders) {
13 |         env.OPENAPI_MCP_HEADERS = config.openapiMcpHeaders;
14 |       }
15 |       if (config.baseUrl) env.BASE_URL = config.baseUrl;
16 |       return { command: 'notion-mcp-server', args: [], env };
17 |     }
18 |   configSchema:
19 |     # JSON Schema defining the configuration options for the MCP.
20 |     type: object
21 |     anyOf:
22 |       - required: [notionToken]
23 |       - required: [openapiMcpHeaders]
24 |     properties:
25 |       notionToken:
26 |         type: string
27 |         description: Notion integration token (recommended)
28 |       openapiMcpHeaders:
29 |         type: string
30 |         default: "{}"
31 |         description: JSON string for HTTP headers, must include Authorization and
32 |           Notion-Version (alternative to notionToken)
33 |       baseUrl:
34 |         type: string
35 |         description: Optional override for Notion API base URL
36 |   exampleConfig:
37 |     notionToken: 'ntn_abcdef'
38 |     baseUrl: https://api.notion.com
39 | 
```

--------------------------------------------------------------------------------
/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": "@notionhq/notion-mcp-server",
 3 |   "keywords": [
 4 |     "notion",
 5 |     "api",
 6 |     "mcp",
 7 |     "server"
 8 |   ],
 9 |   "version": "1.9.0",
10 |   "license": "MIT",
11 |   "type": "module",
12 |   "scripts": {
13 |     "build": "tsc -build && node scripts/build-cli.js",
14 |     "dev": "tsx watch scripts/start-server.ts"
15 |   },
16 |   "bin": {
17 |     "notion-mcp-server": "bin/cli.mjs"
18 |   },
19 |   "dependencies": {
20 |     "@modelcontextprotocol/sdk": "^1.13.3",
21 |     "axios": "^1.8.4",
22 |     "express": "^4.21.2",
23 |     "form-data": "^4.0.1",
24 |     "mustache": "^4.2.0",
25 |     "node-fetch": "^3.3.2",
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 |     "yargs": "^17.7.2",
31 |     "zod": "3.24.1"
32 |   },
33 |   "devDependencies": {
34 |     "@anthropic-ai/sdk": "^0.33.1",
35 |     "@types/express": "^5.0.0",
36 |     "@types/js-yaml": "^4.0.9",
37 |     "@types/json-schema": "^7.0.15",
38 |     "@types/mustache": "^4.2.5",
39 |     "@types/node": "^20.17.16",
40 |     "@types/which": "^3.0.4",
41 |     "@vitest/coverage-v8": "3.1.1",
42 |     "esbuild": "^0.25.2",
43 |     "multer": "1.4.5-lts.1",
44 |     "openai": "^4.91.1",
45 |     "tsx": "^4.19.3",
46 |     "typescript": "^5.8.2",
47 |     "vitest": "^3.1.1"
48 |   },
49 |   "description": "Official MCP server for Notion API",
50 |   "main": "index.js",
51 |   "repository": {
52 |     "type": "git",
53 |     "url": "[email protected]:makenotion/notion-mcp-server.git"
54 |   },
55 |   "author": "@notionhq",
56 |   "bugs": {
57 |     "url": "https://github.com/makenotion/notion-mcp-server/issues"
58 |   },
59 |   "homepage": "https://github.com/makenotion/notion-mcp-server#readme"
60 | }
61 | 
```

--------------------------------------------------------------------------------
/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.integration.test.ts:
--------------------------------------------------------------------------------

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

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

```typescript
  1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'
  2 | import { CallToolRequestSchema, JSONRPCResponse, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
  3 | import { JSONSchema7 as IJsonSchema } from 'json-schema'
  4 | import { OpenAPIToMCPConverter } from '../openapi/parser'
  5 | import { HttpClient, HttpClientError } from '../client/http-client'
  6 | import { OpenAPIV3 } from 'openapi-types'
  7 | import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
  8 | 
  9 | type PathItemObject = OpenAPIV3.PathItemObject & {
 10 |   get?: OpenAPIV3.OperationObject
 11 |   put?: OpenAPIV3.OperationObject
 12 |   post?: OpenAPIV3.OperationObject
 13 |   delete?: OpenAPIV3.OperationObject
 14 |   patch?: OpenAPIV3.OperationObject
 15 | }
 16 | 
 17 | type NewToolDefinition = {
 18 |   methods: Array<{
 19 |     name: string
 20 |     description: string
 21 |     inputSchema: IJsonSchema & { type: 'object' }
 22 |     returnSchema?: IJsonSchema
 23 |   }>
 24 | }
 25 | 
 26 | // import this class, extend and return server
 27 | export class MCPProxy {
 28 |   private server: Server
 29 |   private httpClient: HttpClient
 30 |   private tools: Record<string, NewToolDefinition>
 31 |   private openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
 32 | 
 33 |   constructor(name: string, openApiSpec: OpenAPIV3.Document) {
 34 |     this.server = new Server({ name, version: '1.0.0' }, { capabilities: { tools: {} } })
 35 |     const baseUrl = openApiSpec.servers?.[0].url
 36 |     if (!baseUrl) {
 37 |       throw new Error('No base URL found in OpenAPI spec')
 38 |     }
 39 |     this.httpClient = new HttpClient(
 40 |       {
 41 |         baseUrl,
 42 |         headers: this.parseHeadersFromEnv(),
 43 |       },
 44 |       openApiSpec,
 45 |     )
 46 | 
 47 |     // Convert OpenAPI spec to MCP tools
 48 |     const converter = new OpenAPIToMCPConverter(openApiSpec)
 49 |     const { tools, openApiLookup } = converter.convertToMCPTools()
 50 |     this.tools = tools
 51 |     this.openApiLookup = openApiLookup
 52 | 
 53 |     this.setupHandlers()
 54 |   }
 55 | 
 56 |   private setupHandlers() {
 57 |     // Handle tool listing
 58 |     this.server.setRequestHandler(ListToolsRequestSchema, async () => {
 59 |       const tools: Tool[] = []
 60 | 
 61 |       // Add methods as separate tools to match the MCP format
 62 |       Object.entries(this.tools).forEach(([toolName, def]) => {
 63 |         def.methods.forEach(method => {
 64 |           const toolNameWithMethod = `${toolName}-${method.name}`;
 65 |           const truncatedToolName = this.truncateToolName(toolNameWithMethod);
 66 |           tools.push({
 67 |             name: truncatedToolName,
 68 |             description: method.description,
 69 |             inputSchema: method.inputSchema as Tool['inputSchema'],
 70 |           })
 71 |         })
 72 |       })
 73 | 
 74 |       return { tools }
 75 |     })
 76 | 
 77 |     // Handle tool calling
 78 |     this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
 79 |       const { name, arguments: params } = request.params
 80 | 
 81 |       // Find the operation in OpenAPI spec
 82 |       const operation = this.findOperation(name)
 83 |       if (!operation) {
 84 |         throw new Error(`Method ${name} not found`)
 85 |       }
 86 | 
 87 |       try {
 88 |         // Execute the operation
 89 |         const response = await this.httpClient.executeOperation(operation, params)
 90 | 
 91 |         // Convert response to MCP format
 92 |         return {
 93 |           content: [
 94 |             {
 95 |               type: 'text', // currently this is the only type that seems to be used by mcp server
 96 |               text: JSON.stringify(response.data), // TODO: pass through the http status code text?
 97 |             },
 98 |           ],
 99 |         }
100 |       } catch (error) {
101 |         console.error('Error in tool call', error)
102 |         if (error instanceof HttpClientError) {
103 |           console.error('HttpClientError encountered, returning structured error', error)
104 |           const data = error.data?.response?.data ?? error.data ?? {}
105 |           return {
106 |             content: [
107 |               {
108 |                 type: 'text',
109 |                 text: JSON.stringify({
110 |                   status: 'error', // TODO: get this from http status code?
111 |                   ...(typeof data === 'object' ? data : { data: data }),
112 |                 }),
113 |               },
114 |             ],
115 |           }
116 |         }
117 |         throw error
118 |       }
119 |     })
120 |   }
121 | 
122 |   private findOperation(operationId: string): (OpenAPIV3.OperationObject & { method: string; path: string }) | null {
123 |     return this.openApiLookup[operationId] ?? null
124 |   }
125 | 
126 |   private parseHeadersFromEnv(): Record<string, string> {
127 |     // First try OPENAPI_MCP_HEADERS (existing behavior)
128 |     const headersJson = process.env.OPENAPI_MCP_HEADERS
129 |     if (headersJson) {
130 |       try {
131 |         const headers = JSON.parse(headersJson)
132 |         if (typeof headers !== 'object' || headers === null) {
133 |           console.warn('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', typeof headers)
134 |         } else if (Object.keys(headers).length > 0) {
135 |           // Only use OPENAPI_MCP_HEADERS if it contains actual headers
136 |           return headers
137 |         }
138 |         // If OPENAPI_MCP_HEADERS is empty object, fall through to try NOTION_TOKEN
139 |       } catch (error) {
140 |         console.warn('Failed to parse OPENAPI_MCP_HEADERS environment variable:', error)
141 |         // Fall through to try NOTION_TOKEN
142 |       }
143 |     }
144 | 
145 |     // Alternative: try NOTION_TOKEN
146 |     const notionToken = process.env.NOTION_TOKEN
147 |     if (notionToken) {
148 |       return {
149 |         'Authorization': `Bearer ${notionToken}`,
150 |         'Notion-Version': '2022-06-28'
151 |       }
152 |     }
153 | 
154 |     return {}
155 |   }
156 | 
157 |   private getContentType(headers: Headers): 'text' | 'image' | 'binary' {
158 |     const contentType = headers.get('content-type')
159 |     if (!contentType) return 'binary'
160 | 
161 |     if (contentType.includes('text') || contentType.includes('json')) {
162 |       return 'text'
163 |     } else if (contentType.includes('image')) {
164 |       return 'image'
165 |     }
166 |     return 'binary'
167 |   }
168 | 
169 |   private truncateToolName(name: string): string {
170 |     if (name.length <= 64) {
171 |       return name;
172 |     }
173 |     return name.slice(0, 64);
174 |   }
175 | 
176 |   async connect(transport: Transport) {
177 |     // The SDK will handle stdio communication
178 |     await this.server.connect(transport)
179 |   }
180 | 
181 |   getServer() {
182 |     return this.server
183 |   }
184 | }
185 | 
```

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

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

--------------------------------------------------------------------------------
/scripts/start-server.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import path from 'node:path'
  2 | import { fileURLToPath } from 'url'
  3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
  4 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  5 | import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
  6 | import { randomUUID, randomBytes } from 'node:crypto'
  7 | import express from 'express'
  8 | 
  9 | import { initProxy, ValidationError } from '../src/init-server'
 10 | 
 11 | export async function startServer(args: string[] = process.argv) {
 12 |   const filename = fileURLToPath(import.meta.url)
 13 |   const directory = path.dirname(filename)
 14 |   const specPath = path.resolve(directory, '../scripts/notion-openapi.json')
 15 |   
 16 |   const baseUrl = process.env.BASE_URL ?? undefined
 17 | 
 18 |   // Parse command line arguments manually (similar to slack-mcp approach)
 19 |   function parseArgs() {
 20 |     const args = process.argv.slice(2);
 21 |     let transport = 'stdio'; // default
 22 |     let port = 3000;
 23 |     let authToken: string | undefined;
 24 | 
 25 |     for (let i = 0; i < args.length; i++) {
 26 |       if (args[i] === '--transport' && i + 1 < args.length) {
 27 |         transport = args[i + 1];
 28 |         i++; // skip next argument
 29 |       } else if (args[i] === '--port' && i + 1 < args.length) {
 30 |         port = parseInt(args[i + 1], 10);
 31 |         i++; // skip next argument
 32 |       } else if (args[i] === '--auth-token' && i + 1 < args.length) {
 33 |         authToken = args[i + 1];
 34 |         i++; // skip next argument
 35 |       } else if (args[i] === '--help' || args[i] === '-h') {
 36 |         console.log(`
 37 | Usage: notion-mcp-server [options]
 38 | 
 39 | Options:
 40 |   --transport <type>     Transport type: 'stdio' or 'http' (default: stdio)
 41 |   --port <number>        Port for HTTP server when using Streamable HTTP transport (default: 3000)
 42 |   --auth-token <token>   Bearer token for HTTP transport authentication (optional)
 43 |   --help, -h             Show this help message
 44 | 
 45 | Environment Variables:
 46 |   NOTION_TOKEN           Notion integration token (recommended)
 47 |   OPENAPI_MCP_HEADERS    JSON string with Notion API headers (alternative)
 48 |   AUTH_TOKEN             Bearer token for HTTP transport authentication (alternative to --auth-token)
 49 | 
 50 | Examples:
 51 |   notion-mcp-server                                    # Use stdio transport (default)
 52 |   notion-mcp-server --transport stdio                  # Use stdio transport explicitly
 53 |   notion-mcp-server --transport http                   # Use Streamable HTTP transport on port 3000
 54 |   notion-mcp-server --transport http --port 8080       # Use Streamable HTTP transport on port 8080
 55 |   notion-mcp-server --transport http --auth-token mytoken # Use Streamable HTTP transport with custom auth token
 56 |   AUTH_TOKEN=mytoken notion-mcp-server --transport http # Use Streamable HTTP transport with auth token from env var
 57 | `);
 58 |         process.exit(0);
 59 |       }
 60 |       // Ignore unrecognized arguments (like command name passed by Docker)
 61 |     }
 62 | 
 63 |     return { transport: transport.toLowerCase(), port, authToken };
 64 |   }
 65 | 
 66 |   const options = parseArgs()
 67 |   const transport = options.transport
 68 | 
 69 |   if (transport === 'stdio') {
 70 |     // Use stdio transport (default)
 71 |     const proxy = await initProxy(specPath, baseUrl)
 72 |     await proxy.connect(new StdioServerTransport())
 73 |     return proxy.getServer()
 74 |   } else if (transport === 'http') {
 75 |     // Use Streamable HTTP transport
 76 |     const app = express()
 77 |     app.use(express.json())
 78 | 
 79 |     // Generate or use provided auth token (from CLI arg or env var)
 80 |     const authToken = options.authToken || process.env.AUTH_TOKEN || randomBytes(32).toString('hex')
 81 |     if (!options.authToken && !process.env.AUTH_TOKEN) {
 82 |       console.log(`Generated auth token: ${authToken}`)
 83 |       console.log(`Use this token in the Authorization header: Bearer ${authToken}`)
 84 |     }
 85 | 
 86 |     // Authorization middleware
 87 |     const authenticateToken = (req: express.Request, res: express.Response, next: express.NextFunction): void => {
 88 |       const authHeader = req.headers['authorization']
 89 |       const token = authHeader && authHeader.split(' ')[1] // Bearer TOKEN
 90 | 
 91 |       if (!token) {
 92 |         res.status(401).json({
 93 |           jsonrpc: '2.0',
 94 |           error: {
 95 |             code: -32001,
 96 |             message: 'Unauthorized: Missing bearer token',
 97 |           },
 98 |           id: null,
 99 |         })
100 |         return
101 |       }
102 | 
103 |       if (token !== authToken) {
104 |         res.status(403).json({
105 |           jsonrpc: '2.0',
106 |           error: {
107 |             code: -32002,
108 |             message: 'Forbidden: Invalid bearer token',
109 |           },
110 |           id: null,
111 |         })
112 |         return
113 |       }
114 | 
115 |       next()
116 |     }
117 | 
118 |     // Health endpoint (no authentication required)
119 |     app.get('/health', (req, res) => {
120 |       res.status(200).json({
121 |         status: 'healthy',
122 |         timestamp: new Date().toISOString(),
123 |         transport: 'http',
124 |         port: options.port
125 |       })
126 |     })
127 | 
128 |     // Apply authentication to all /mcp routes
129 |     app.use('/mcp', authenticateToken)
130 | 
131 |     // Map to store transports by session ID
132 |     const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
133 | 
134 |     // Handle POST requests for client-to-server communication
135 |     app.post('/mcp', async (req, res) => {
136 |       try {
137 |         // Check for existing session ID
138 |         const sessionId = req.headers['mcp-session-id'] as string | undefined
139 |         let transport: StreamableHTTPServerTransport
140 | 
141 |         if (sessionId && transports[sessionId]) {
142 |           // Reuse existing transport
143 |           transport = transports[sessionId]
144 |         } else if (!sessionId && isInitializeRequest(req.body)) {
145 |           // New initialization request
146 |           transport = new StreamableHTTPServerTransport({
147 |             sessionIdGenerator: () => randomUUID(),
148 |             onsessioninitialized: (sessionId) => {
149 |               // Store the transport by session ID
150 |               transports[sessionId] = transport
151 |             }
152 |           })
153 | 
154 |           // Clean up transport when closed
155 |           transport.onclose = () => {
156 |             if (transport.sessionId) {
157 |               delete transports[transport.sessionId]
158 |             }
159 |           }
160 | 
161 |           const proxy = await initProxy(specPath, baseUrl)
162 |           await proxy.connect(transport)
163 |         } else {
164 |           // Invalid request
165 |           res.status(400).json({
166 |             jsonrpc: '2.0',
167 |             error: {
168 |               code: -32000,
169 |               message: 'Bad Request: No valid session ID provided',
170 |             },
171 |             id: null,
172 |           })
173 |           return
174 |         }
175 | 
176 |         // Handle the request
177 |         await transport.handleRequest(req, res, req.body)
178 |       } catch (error) {
179 |         console.error('Error handling MCP request:', error)
180 |         if (!res.headersSent) {
181 |           res.status(500).json({
182 |             jsonrpc: '2.0',
183 |             error: {
184 |               code: -32603,
185 |               message: 'Internal server error',
186 |             },
187 |             id: null,
188 |           })
189 |         }
190 |       }
191 |     })
192 | 
193 |     // Handle GET requests for server-to-client notifications via Streamable HTTP
194 |     app.get('/mcp', async (req, res) => {
195 |       const sessionId = req.headers['mcp-session-id'] as string | undefined
196 |       if (!sessionId || !transports[sessionId]) {
197 |         res.status(400).send('Invalid or missing session ID')
198 |         return
199 |       }
200 |       
201 |       const transport = transports[sessionId]
202 |       await transport.handleRequest(req, res)
203 |     })
204 | 
205 |     // Handle DELETE requests for session termination
206 |     app.delete('/mcp', async (req, res) => {
207 |       const sessionId = req.headers['mcp-session-id'] as string | undefined
208 |       if (!sessionId || !transports[sessionId]) {
209 |         res.status(400).send('Invalid or missing session ID')
210 |         return
211 |       }
212 |       
213 |       const transport = transports[sessionId]
214 |       await transport.handleRequest(req, res)
215 |     })
216 | 
217 |     const port = options.port
218 |     app.listen(port, '0.0.0.0', () => {
219 |       console.log(`MCP Server listening on port ${port}`)
220 |       console.log(`Endpoint: http://0.0.0.0:${port}/mcp`)
221 |       console.log(`Health check: http://0.0.0.0:${port}/health`)
222 |       console.log(`Authentication: Bearer token required`)
223 |       if (options.authToken) {
224 |         console.log(`Using provided auth token`)
225 |       }
226 |     })
227 | 
228 |     // Return a dummy server for compatibility
229 |     return { close: () => {} }
230 |   } else {
231 |     throw new Error(`Unsupported transport: ${transport}. Use 'stdio' or 'http'.`)
232 |   }
233 | }
234 | 
235 | startServer(process.argv).catch(error => {
236 |   if (error instanceof ValidationError) {
237 |     console.error('Invalid OpenAPI 3.1 specification:')
238 |     error.errors.forEach(err => console.error(err))
239 |   } else {
240 |     console.error('Error:', error)
241 |   }
242 |   process.exit(1)
243 | })
244 | 
```

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

```typescript
  1 | import { MCPProxy } from '../proxy'
  2 | import { OpenAPIV3 } from 'openapi-types'
  3 | import { HttpClient } from '../../client/http-client'
  4 | import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
  5 | import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
  6 | 
  7 | // Mock the dependencies
  8 | vi.mock('../../client/http-client')
  9 | vi.mock('@modelcontextprotocol/sdk/server/index.js')
 10 | 
 11 | describe('MCPProxy', () => {
 12 |   let proxy: MCPProxy
 13 |   let mockOpenApiSpec: OpenAPIV3.Document
 14 | 
 15 |   beforeEach(() => {
 16 |     // Reset all mocks
 17 |     vi.clearAllMocks()
 18 | 
 19 |     // Setup minimal OpenAPI spec for testing
 20 |     mockOpenApiSpec = {
 21 |       openapi: '3.0.0',
 22 |       servers: [{ url: 'http://localhost:3000' }],
 23 |       info: {
 24 |         title: 'Test API',
 25 |         version: '1.0.0',
 26 |       },
 27 |       paths: {
 28 |         '/test': {
 29 |           get: {
 30 |             operationId: 'getTest',
 31 |             responses: {
 32 |               '200': {
 33 |                 description: 'Success',
 34 |               },
 35 |             },
 36 |           },
 37 |         },
 38 |       },
 39 |     }
 40 | 
 41 |     proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
 42 |   })
 43 | 
 44 |   describe('listTools handler', () => {
 45 |     it('should return converted tools from OpenAPI spec', async () => {
 46 |       const server = (proxy as any).server
 47 |       const listToolsHandler = server.setRequestHandler.mock.calls[0].filter((x: unknown) => typeof x === 'function')[0]
 48 |       const result = await listToolsHandler()
 49 | 
 50 |       expect(result).toHaveProperty('tools')
 51 |       expect(Array.isArray(result.tools)).toBe(true)
 52 |     })
 53 | 
 54 |     it('should truncate tool names exceeding 64 characters', async () => {
 55 |       // Setup OpenAPI spec with long tool names
 56 |       mockOpenApiSpec.paths = {
 57 |         '/test': {
 58 |           get: {
 59 |             operationId: 'a'.repeat(65),
 60 |             responses: {
 61 |               '200': {
 62 |                 description: 'Success'
 63 |               }
 64 |             }
 65 |           }
 66 |         }
 67 |       }
 68 |       proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
 69 |       const server = (proxy as any).server
 70 |       const listToolsHandler = server.setRequestHandler.mock.calls[0].filter((x: unknown) => typeof x === 'function')[0];
 71 |       const result = await listToolsHandler()
 72 | 
 73 |       expect(result.tools[0].name.length).toBeLessThanOrEqual(64)
 74 |     })
 75 |   })
 76 | 
 77 |   describe('callTool handler', () => {
 78 |     it('should execute operation and return formatted response', async () => {
 79 |       // Mock HttpClient response
 80 |       const mockResponse = {
 81 |         data: { message: 'success' },
 82 |         status: 200,
 83 |         headers: new Headers({
 84 |           'content-type': 'application/json',
 85 |         }),
 86 |       }
 87 |       ;(HttpClient.prototype.executeOperation as ReturnType<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 |       ).rejects.toThrow('Method nonExistentMethod not found')
133 |     })
134 | 
135 |     it('should handle tool names exceeding 64 characters', async () => {
136 |       // Mock HttpClient response
137 |       const mockResponse = {
138 |         data: { message: 'success' },
139 |         status: 200,
140 |         headers: new Headers({
141 |           'content-type': 'application/json'
142 |         })
143 |       };
144 |       (HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
145 | 
146 |       // Set up the openApiLookup with a long tool name
147 |       const longToolName = 'a'.repeat(65)
148 |       const truncatedToolName = longToolName.slice(0, 64)
149 |       ;(proxy as any).openApiLookup = {
150 |         [truncatedToolName]: {
151 |           operationId: longToolName,
152 |           responses: { '200': { description: 'Success' } },
153 |           method: 'get',
154 |           path: '/test'
155 |         }
156 |       };
157 | 
158 |       const server = (proxy as any).server;
159 |       const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function');
160 |       const callToolHandler = handlers[1];
161 | 
162 |       const result = await callToolHandler({
163 |         params: {
164 |           name: truncatedToolName,
165 |           arguments: {}
166 |         }
167 |       })
168 | 
169 |       expect(result).toEqual({
170 |         content: [
171 |           {
172 |             type: 'text',
173 |             text: JSON.stringify({ message: 'success' })
174 |           }
175 |         ]
176 |       })
177 |     })
178 |   })
179 | 
180 |   describe('getContentType', () => {
181 |     it('should return correct content type for different headers', () => {
182 |       const getContentType = (proxy as any).getContentType.bind(proxy)
183 | 
184 |       expect(getContentType(new Headers({ 'content-type': 'text/plain' }))).toBe('text')
185 |       expect(getContentType(new Headers({ 'content-type': 'application/json' }))).toBe('text')
186 |       expect(getContentType(new Headers({ 'content-type': 'image/jpeg' }))).toBe('image')
187 |       expect(getContentType(new Headers({ 'content-type': 'application/octet-stream' }))).toBe('binary')
188 |       expect(getContentType(new Headers())).toBe('binary')
189 |     })
190 |   })
191 | 
192 |   describe('parseHeadersFromEnv', () => {
193 |     const originalEnv = process.env
194 | 
195 |     beforeEach(() => {
196 |       process.env = { ...originalEnv }
197 |     })
198 | 
199 |     afterEach(() => {
200 |       process.env = originalEnv
201 |     })
202 | 
203 |     it('should parse valid JSON headers from env', () => {
204 |       process.env.OPENAPI_MCP_HEADERS = JSON.stringify({
205 |         Authorization: 'Bearer token123',
206 |         'X-Custom-Header': 'test',
207 |       })
208 | 
209 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
210 |       expect(HttpClient).toHaveBeenCalledWith(
211 |         expect.objectContaining({
212 |           headers: {
213 |             Authorization: 'Bearer token123',
214 |             'X-Custom-Header': 'test',
215 |           },
216 |         }),
217 |         expect.anything(),
218 |       )
219 |     })
220 | 
221 |     it('should return empty object when env var is not set', () => {
222 |       delete process.env.OPENAPI_MCP_HEADERS
223 | 
224 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
225 |       expect(HttpClient).toHaveBeenCalledWith(
226 |         expect.objectContaining({
227 |           headers: {},
228 |         }),
229 |         expect.anything(),
230 |       )
231 |     })
232 | 
233 |     it('should return empty object and warn on invalid JSON', () => {
234 |       const consoleSpy = vi.spyOn(console, 'warn')
235 |       process.env.OPENAPI_MCP_HEADERS = 'invalid json'
236 | 
237 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
238 |       expect(HttpClient).toHaveBeenCalledWith(
239 |         expect.objectContaining({
240 |           headers: {},
241 |         }),
242 |         expect.anything(),
243 |       )
244 |       expect(consoleSpy).toHaveBeenCalledWith('Failed to parse OPENAPI_MCP_HEADERS environment variable:', expect.any(Error))
245 |     })
246 | 
247 |     it('should return empty object and warn on non-object JSON', () => {
248 |       const consoleSpy = vi.spyOn(console, 'warn')
249 |       process.env.OPENAPI_MCP_HEADERS = '"string"'
250 | 
251 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
252 |       expect(HttpClient).toHaveBeenCalledWith(
253 |         expect.objectContaining({
254 |           headers: {},
255 |         }),
256 |         expect.anything(),
257 |       )
258 |       expect(consoleSpy).toHaveBeenCalledWith('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', 'string')
259 |     })
260 | 
261 |     it('should use NOTION_TOKEN when OPENAPI_MCP_HEADERS is not set', () => {
262 |       delete process.env.OPENAPI_MCP_HEADERS
263 |       process.env.NOTION_TOKEN = 'ntn_test_token_123'
264 | 
265 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
266 |       expect(HttpClient).toHaveBeenCalledWith(
267 |         expect.objectContaining({
268 |           headers: {
269 |             'Authorization': 'Bearer ntn_test_token_123',
270 |             'Notion-Version': '2022-06-28'
271 |           },
272 |         }),
273 |         expect.anything(),
274 |       )
275 |     })
276 | 
277 |     it('should prioritize OPENAPI_MCP_HEADERS over NOTION_TOKEN when both are set', () => {
278 |       process.env.OPENAPI_MCP_HEADERS = JSON.stringify({
279 |         Authorization: 'Bearer custom_token',
280 |         'Custom-Header': 'custom_value',
281 |       })
282 |       process.env.NOTION_TOKEN = 'ntn_test_token_123'
283 | 
284 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
285 |       expect(HttpClient).toHaveBeenCalledWith(
286 |         expect.objectContaining({
287 |           headers: {
288 |             Authorization: 'Bearer custom_token',
289 |             'Custom-Header': 'custom_value',
290 |           },
291 |         }),
292 |         expect.anything(),
293 |       )
294 |     })
295 | 
296 |     it('should return empty object when neither OPENAPI_MCP_HEADERS nor NOTION_TOKEN are set', () => {
297 |       delete process.env.OPENAPI_MCP_HEADERS
298 |       delete process.env.NOTION_TOKEN
299 | 
300 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
301 |       expect(HttpClient).toHaveBeenCalledWith(
302 |         expect.objectContaining({
303 |           headers: {},
304 |         }),
305 |         expect.anything(),
306 |       )
307 |     })
308 | 
309 |     it('should use NOTION_TOKEN when OPENAPI_MCP_HEADERS is empty object', () => {
310 |       process.env.OPENAPI_MCP_HEADERS = '{}'
311 |       process.env.NOTION_TOKEN = 'ntn_test_token_123'
312 | 
313 |       const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
314 |       expect(HttpClient).toHaveBeenCalledWith(
315 |         expect.objectContaining({
316 |           headers: {
317 |             'Authorization': 'Bearer ntn_test_token_123',
318 |             'Notion-Version': '2022-06-28'
319 |           },
320 |         }),
321 |         expect.anything(),
322 |       )
323 |     })
324 |   })
325 |   describe('connect', () => {
326 |     it('should connect to transport', async () => {
327 |       const mockTransport = {} as Transport
328 |       await proxy.connect(mockTransport)
329 | 
330 |       const server = (proxy as any).server
331 |       expect(server.connect).toHaveBeenCalledWith(mockTransport)
332 |     })
333 |   })
334 | })
335 | 
```

--------------------------------------------------------------------------------
/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 |           mcpMethod.description = this.getDescription(operation.summary || operation.description || '')
189 |           tools[apiName]!.methods.push(mcpMethod)
190 |           openApiLookup[apiName + '-' + uniqueName] = { ...operation, method, path }
191 |           zip[apiName + '-' + uniqueName] = { openApi: { ...operation, method, path }, mcp: mcpMethod }
192 |         }
193 |       }
194 |     }
195 | 
196 |     return { tools, openApiLookup, zip }
197 |   }
198 | 
199 |   /**
200 |    * Convert the OpenAPI spec to OpenAI's ChatCompletionTool format
201 |    */
202 |   convertToOpenAITools(): ChatCompletionTool[] {
203 |     const tools: ChatCompletionTool[] = []
204 | 
205 |     for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
206 |       if (!pathItem) continue
207 | 
208 |       for (const [method, operation] of Object.entries(pathItem)) {
209 |         if (!this.isOperation(method, operation)) continue
210 | 
211 |         const parameters = this.convertOperationToJsonSchema(operation, method, path)
212 |         const tool: ChatCompletionTool = {
213 |           type: 'function',
214 |           function: {
215 |             name: operation.operationId!,
216 |             description: this.getDescription(operation.summary || operation.description || ''),
217 |             parameters: parameters as FunctionParameters,
218 |           },
219 |         }
220 |         tools.push(tool)
221 |       }
222 |     }
223 | 
224 |     return tools
225 |   }
226 | 
227 |   /**
228 |    * Convert the OpenAPI spec to Anthropic's Tool format
229 |    */
230 |   convertToAnthropicTools(): Tool[] {
231 |     const tools: Tool[] = []
232 | 
233 |     for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
234 |       if (!pathItem) continue
235 | 
236 |       for (const [method, operation] of Object.entries(pathItem)) {
237 |         if (!this.isOperation(method, operation)) continue
238 | 
239 |         const parameters = this.convertOperationToJsonSchema(operation, method, path)
240 |         const tool: Tool = {
241 |           name: operation.operationId!,
242 |           description: this.getDescription(operation.summary || operation.description || ''),
243 |           input_schema: parameters as Tool['input_schema'],
244 |         }
245 |         tools.push(tool)
246 |       }
247 |     }
248 | 
249 |     return tools
250 |   }
251 | 
252 |   private convertComponentsToJsonSchema(): Record<string, IJsonSchema> {
253 |     const components = this.openApiSpec.components || {}
254 |     const schema: Record<string, IJsonSchema> = {}
255 |     for (const [key, value] of Object.entries(components.schemas || {})) {
256 |       schema[key] = this.convertOpenApiSchemaToJsonSchema(value, new Set())
257 |     }
258 |     return schema
259 |   }
260 |   /**
261 |    * Helper method to convert an operation to a JSON Schema for parameters
262 |    */
263 |   private convertOperationToJsonSchema(
264 |     operation: OpenAPIV3.OperationObject,
265 |     method: string,
266 |     path: string,
267 |   ): IJsonSchema & { type: 'object' } {
268 |     const schema: IJsonSchema & { type: 'object' } = {
269 |       type: 'object',
270 |       properties: {},
271 |       required: [],
272 |       $defs: this.convertComponentsToJsonSchema(),
273 |     }
274 | 
275 |     // Handle parameters (path, query, header, cookie)
276 |     if (operation.parameters) {
277 |       for (const param of operation.parameters) {
278 |         const paramObj = this.resolveParameter(param)
279 |         if (paramObj && paramObj.schema) {
280 |           const paramSchema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set())
281 |           // Merge parameter-level description if available
282 |           if (paramObj.description) {
283 |             paramSchema.description = paramObj.description
284 |           }
285 |           schema.properties![paramObj.name] = paramSchema
286 |           if (paramObj.required) {
287 |             schema.required!.push(paramObj.name)
288 |           }
289 |         }
290 |       }
291 |     }
292 | 
293 |     // Handle requestBody
294 |     if (operation.requestBody) {
295 |       const bodyObj = this.resolveRequestBody(operation.requestBody)
296 |       if (bodyObj?.content) {
297 |         if (bodyObj.content['application/json']?.schema) {
298 |           const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set())
299 |           if (bodySchema.type === 'object' && bodySchema.properties) {
300 |             for (const [name, propSchema] of Object.entries(bodySchema.properties)) {
301 |               schema.properties![name] = propSchema
302 |             }
303 |             if (bodySchema.required) {
304 |               schema.required!.push(...bodySchema.required)
305 |             }
306 |           }
307 |         }
308 |       }
309 |     }
310 | 
311 |     return schema
312 |   }
313 | 
314 |   private isOperation(method: string, operation: any): operation is OpenAPIV3.OperationObject {
315 |     return ['get', 'post', 'put', 'delete', 'patch'].includes(method.toLowerCase())
316 |   }
317 | 
318 |   private isParameterObject(param: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject): param is OpenAPIV3.ParameterObject {
319 |     return !('$ref' in param)
320 |   }
321 | 
322 |   private isRequestBodyObject(body: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject): body is OpenAPIV3.RequestBodyObject {
323 |     return !('$ref' in body)
324 |   }
325 | 
326 |   private resolveParameter(param: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject): OpenAPIV3.ParameterObject | null {
327 |     if (this.isParameterObject(param)) {
328 |       return param
329 |     } else {
330 |       const resolved = this.internalResolveRef(param.$ref, new Set())
331 |       if (resolved && (resolved as OpenAPIV3.ParameterObject).name) {
332 |         return resolved as OpenAPIV3.ParameterObject
333 |       }
334 |     }
335 |     return null
336 |   }
337 | 
338 |   private resolveRequestBody(body: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject): OpenAPIV3.RequestBodyObject | null {
339 |     if (this.isRequestBodyObject(body)) {
340 |       return body
341 |     } else {
342 |       const resolved = this.internalResolveRef(body.$ref, new Set())
343 |       if (resolved) {
344 |         return resolved as OpenAPIV3.RequestBodyObject
345 |       }
346 |     }
347 |     return null
348 |   }
349 | 
350 |   private resolveResponse(response: OpenAPIV3.ResponseObject | OpenAPIV3.ReferenceObject): OpenAPIV3.ResponseObject | null {
351 |     if ('$ref' in response) {
352 |       const resolved = this.internalResolveRef(response.$ref, new Set())
353 |       if (resolved) {
354 |         return resolved as OpenAPIV3.ResponseObject
355 |       } else {
356 |         return null
357 |       }
358 |     }
359 |     return response
360 |   }
361 | 
362 |   private convertOperationToMCPMethod(operation: OpenAPIV3.OperationObject, method: string, path: string): NewToolMethod | null {
363 |     if (!operation.operationId) {
364 |       console.warn(`Operation without operationId at ${method} ${path}`)
365 |       return null
366 |     }
367 | 
368 |     const methodName = operation.operationId
369 | 
370 |     const inputSchema: IJsonSchema & { type: 'object' } = {
371 |       $defs: this.convertComponentsToJsonSchema(),
372 |       type: 'object',
373 |       properties: {},
374 |       required: [],
375 |     }
376 | 
377 |     // Handle parameters (path, query, header, cookie)
378 |     if (operation.parameters) {
379 |       for (const param of operation.parameters) {
380 |         const paramObj = this.resolveParameter(param)
381 |         if (paramObj && paramObj.schema) {
382 |           const schema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set(), false)
383 |           // Merge parameter-level description if available
384 |           if (paramObj.description) {
385 |             schema.description = paramObj.description
386 |           }
387 |           inputSchema.properties![paramObj.name] = schema
388 |           if (paramObj.required) {
389 |             inputSchema.required!.push(paramObj.name)
390 |           }
391 |         }
392 |       }
393 |     }
394 | 
395 |     // Handle requestBody
396 |     if (operation.requestBody) {
397 |       const bodyObj = this.resolveRequestBody(operation.requestBody)
398 |       if (bodyObj?.content) {
399 |         // Handle multipart/form-data for file uploads
400 |         // We convert the multipart/form-data schema to a JSON schema and we require
401 |         // that the user passes in a string for each file that points to the local file
402 |         if (bodyObj.content['multipart/form-data']?.schema) {
403 |           const formSchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['multipart/form-data'].schema, new Set(), false)
404 |           if (formSchema.type === 'object' && formSchema.properties) {
405 |             for (const [name, propSchema] of Object.entries(formSchema.properties)) {
406 |               inputSchema.properties![name] = propSchema
407 |             }
408 |             if (formSchema.required) {
409 |               inputSchema.required!.push(...formSchema.required!)
410 |             }
411 |           }
412 |         }
413 |         // Handle application/json
414 |         else if (bodyObj.content['application/json']?.schema) {
415 |           const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set(), false)
416 |           // Merge body schema into the inputSchema's properties
417 |           if (bodySchema.type === 'object' && bodySchema.properties) {
418 |             for (const [name, propSchema] of Object.entries(bodySchema.properties)) {
419 |               inputSchema.properties![name] = propSchema
420 |             }
421 |             if (bodySchema.required) {
422 |               inputSchema.required!.push(...bodySchema.required!)
423 |             }
424 |           } else {
425 |             // If the request body is not an object, just put it under "body"
426 |             inputSchema.properties!['body'] = bodySchema
427 |             inputSchema.required!.push('body')
428 |           }
429 |         }
430 |       }
431 |     }
432 | 
433 |     // Build description including error responses
434 |     let description = operation.summary || operation.description || ''
435 |     if (operation.responses) {
436 |       const errorResponses = Object.entries(operation.responses)
437 |         .filter(([code]) => code.startsWith('4') || code.startsWith('5'))
438 |         .map(([code, response]) => {
439 |           const responseObj = this.resolveResponse(response)
440 |           let errorDesc = responseObj?.description || ''
441 |           return `${code}: ${errorDesc}`
442 |         })
443 | 
444 |       if (errorResponses.length > 0) {
445 |         description += '\nError Responses:\n' + errorResponses.join('\n')
446 |       }
447 |     }
448 | 
449 |     // Extract return type (response schema)
450 |     const returnSchema = this.extractResponseType(operation.responses)
451 | 
452 |     // Generate Zod schema from input schema
453 |     try {
454 |       // const zodSchemaStr = jsonSchemaToZod(inputSchema, { module: "cjs" })
455 |       // console.log(zodSchemaStr)
456 |       // // Execute the function with the zod instance
457 |       // const zodSchema = eval(zodSchemaStr) as z.ZodType
458 | 
459 |       return {
460 |         name: methodName,
461 |         description,
462 |         inputSchema,
463 |         ...(returnSchema ? { returnSchema } : {}),
464 |       }
465 |     } catch (error) {
466 |       console.warn(`Failed to generate Zod schema for ${methodName}:`, error)
467 |       // Fallback to a basic object schema
468 |       return {
469 |         name: methodName,
470 |         description,
471 |         inputSchema,
472 |         ...(returnSchema ? { returnSchema } : {}),
473 |       }
474 |     }
475 |   }
476 | 
477 |   private extractResponseType(responses: OpenAPIV3.ResponsesObject | undefined): IJsonSchema | null {
478 |     // Look for a success response
479 |     const successResponse = responses?.['200'] || responses?.['201'] || responses?.['202'] || responses?.['204']
480 |     if (!successResponse) return null
481 | 
482 |     const responseObj = this.resolveResponse(successResponse)
483 |     if (!responseObj || !responseObj.content) return null
484 | 
485 |     if (responseObj.content['application/json']?.schema) {
486 |       const returnSchema = this.convertOpenApiSchemaToJsonSchema(responseObj.content['application/json'].schema, new Set(), false)
487 |       returnSchema['$defs'] = this.convertComponentsToJsonSchema()
488 | 
489 |       // Preserve the response description if available and not already set
490 |       if (responseObj.description && !returnSchema.description) {
491 |         returnSchema.description = responseObj.description
492 |       }
493 | 
494 |       return returnSchema
495 |     }
496 | 
497 |     // If no JSON response, fallback to a generic string or known formats
498 |     if (responseObj.content['image/png'] || responseObj.content['image/jpeg']) {
499 |       return { type: 'string', format: 'binary', description: responseObj.description || '' }
500 |     }
501 | 
502 |     // Fallback
503 |     return { type: 'string', description: responseObj.description || '' }
504 |   }
505 | 
506 |   private ensureUniqueName(name: string): string {
507 |     if (name.length <= 64) {
508 |       return name
509 |     }
510 | 
511 |     const truncatedName = name.slice(0, 64 - 5) // Reserve space for suffix
512 |     const uniqueSuffix = this.generateUniqueSuffix()
513 |     return `${truncatedName}-${uniqueSuffix}`
514 |   }
515 | 
516 |   private generateUniqueSuffix(): string {
517 |     this.nameCounter += 1
518 |     return this.nameCounter.toString().padStart(4, '0')
519 |   }
520 | 
521 |   private getDescription(description: string): string {
522 |     return "Notion | " + description
523 |   }
524 | }
525 | 
```

--------------------------------------------------------------------------------
/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