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 | 
13 |
14 | This project implements an [MCP server](https://spec.modelcontextprotocol.io/) for the [Notion API](https://developers.notion.com/reference/intro).
15 |
16 | 
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 | 
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 | 
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 | 
36 |
37 | 
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 | 
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 | 
208 |
209 |
210 | #### Installing via Smithery
211 |
212 | [](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 |
```