This is page 1 of 2. Use http://codebase.md/taewoong1378/notion-readonly-mcp-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .dockerignore
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── docs
│ └── images
│ ├── connections.png
│ ├── integrations-capabilities.png
│ ├── integrations-creation.png
│ └── notion-api-tools-comparison.png
├── examples
│ └── petstore-server.cjs
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── scripts
│ ├── build-cli.js
│ ├── notion-openapi.json
│ └── start-server.ts
├── src
│ ├── init-server.ts
│ └── openapi-mcp-server
│ ├── auth
│ │ ├── index.ts
│ │ ├── template.ts
│ │ └── types.ts
│ ├── client
│ │ ├── __tests__
│ │ │ ├── http-client-upload.test.ts
│ │ │ ├── http-client.integration.test.ts
│ │ │ └── http-client.test.ts
│ │ └── http-client.ts
│ ├── index.ts
│ ├── mcp
│ │ ├── __tests__
│ │ │ ├── one-pager.test.ts
│ │ │ └── proxy.test.ts
│ │ └── proxy.ts
│ ├── openapi
│ │ ├── __tests__
│ │ │ ├── file-upload.test.ts
│ │ │ ├── parser-multipart.test.ts
│ │ │ └── parser.test.ts
│ │ ├── file-upload.ts
│ │ └── parser.ts
│ └── README.md
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
1 | node_modules
2 | Dockerfile
3 | docker-compose.yml
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | node_modules/
2 | build/
3 | dist
4 | bin/
5 |
6 | .cache
7 | .yarn/cache
8 | .eslintcache
9 |
10 | .cursor
11 |
12 | .DS_Store
13 |
14 | .env
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/README.md:
--------------------------------------------------------------------------------
```markdown
1 | Note: This is a fork from v1 of https://github.com/snaggle-ai/openapi-mcp-server. The library took a different direction with v2 which is not compatible with our development approach.
2 |
3 | Forked to upgrade vulnerable dependencies and easier setup.
4 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Notion ReadOnly MCP Server
2 |
3 | This project implements an optimized read-only MCP server for the Notion API, focusing on performance and efficiency for AI assistants to query and retrieve Notion content.
4 |
5 | <a href="https://glama.ai/mcp/servers/@Taewoong1378/notion-readonly-mcp-server">
6 | <img width="380" height="200" src="https://glama.ai/mcp/servers/@Taewoong1378/notion-readonly-mcp-server/badge" alt="Notion ReadOnly Server MCP server" />
7 | </a>
8 |
9 | ## Key Improvements
10 |
11 | - **Read-Only Design**: Focused exclusively on data retrieval operations, ensuring safe access to Notion content.
12 | - **Minimized Tool Set**: Reduced the number of exposed Notion API tools from 15+ to only 6 essential ones for document analysis.
13 | - **Parallel Processing**: Enhanced performance by implementing asynchronous and parallel API requests for retrieving block content, significantly reducing response times.
14 | - **Extended Database Access**: Added support for database, page property, and comment retrieval operations.
15 | - **Optimized for AI Assistants**: Significantly reduced tool count addresses the "Too many tools can degrade performance" issue in AI assistants like Cursor, which limits models to approximately 40 tools.
16 |
17 | ## Tool Comparison
18 |
19 | This read-only implementation exposes far fewer tools compared to the standard Notion API integration, improving performance and compatibility with AI assistants:
20 |
21 | 
22 |
23 | The reduced tool set helps stay within the recommended tool limits for optimal AI assistant performance while still providing all essential functionality.
24 |
25 | ## Installation
26 |
27 | ### 1. Setting up Integration in Notion:
28 |
29 | Go to https://www.notion.so/profile/integrations and create a new **internal** integration or select an existing one.
30 |
31 | 
32 |
33 | While we limit the scope of Notion API's exposed to read-only operations, there is a non-zero risk to workspace data by exposing it to LLMs. Security-conscious users may want to further configure the Integration's _Capabilities_.
34 |
35 | For example, you can create a read-only integration token by giving only "Read content" access from the "Configuration" tab:
36 |
37 | 
38 |
39 | ### 2. Adding MCP config to your client:
40 |
41 | #### Using npm:
42 |
43 | Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json` (MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json`)
44 |
45 | ```json
46 | {
47 | "mcpServers": {
48 | "notionApi": {
49 | "command": "npx",
50 | "args": ["-y", "notion-readonly-mcp-server"],
51 | "env": {
52 | "OPENAPI_MCP_HEADERS": "{\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\" }"
53 | }
54 | }
55 | }
56 | }
57 | ```
58 |
59 | #### Using Docker:
60 |
61 | Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json`:
62 |
63 | ```json
64 | {
65 | "mcpServers": {
66 | "notionApi": {
67 | "command": "docker",
68 | "args": [
69 | "run",
70 | "--rm",
71 | "-i",
72 | "-e", "OPENAPI_MCP_HEADERS",
73 | "taewoong1378/notion-readonly-mcp-server"
74 | ],
75 | "env": {
76 | "OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer ntn_****\",\"Notion-Version\":\"2022-06-28\"}"
77 | }
78 | }
79 | }
80 | }
81 | ```
82 |
83 | Don't forget to replace `ntn_****` with your integration secret. Find it from your integration configuration tab.
84 |
85 | ### 3. Connecting content to integration:
86 |
87 | Ensure relevant pages and databases are connected to your integration.
88 |
89 | To do this, visit the page, click on the 3 dots, and select "Connect to integration".
90 |
91 | 
92 |
93 | ## Available Tools
94 |
95 | This optimized server exposes only essential read-only Notion API tools:
96 |
97 | - `API-retrieve-a-page`: Get page information
98 | - `API-get-block-children`: Get page content blocks (with parallel processing)
99 | - `API-retrieve-a-block`: Get details about a specific block
100 | - `API-retrieve-a-database`: Get database information
101 | - `API-retrieve-a-comment`: Get comments on a page or block
102 | - `API-retrieve-a-page-property`: Get specific property information from a page
103 | - `API-get-one-pager`: **NEW!** Recursively retrieve a full Notion page with all its blocks, databases, and related content in a single call
104 |
105 | By limiting to these 7 essential tools (compared to 15+ in the standard implementation), we ensure:
106 |
107 | 1. Better performance in AI assistants like Cursor and Claude that have tool count limitations
108 | 2. Reduced cognitive load for AI models when choosing appropriate tools
109 | 3. Faster response times with fewer API options to consider
110 | 4. Enhanced security through minimized API surface area
111 |
112 | ## Automatic Content Exploration
113 |
114 | The new `API-get-one-pager` tool provides a powerful way to explore Notion pages without requiring multiple API calls:
115 |
116 | - **Recursive retrieval**: Automatically traverses the entire page structure including nested blocks
117 | - **Parallel processing**: Fetches multiple blocks and their children simultaneously for maximum performance
118 | - **Intelligent caching**: Stores retrieved data to minimize redundant API calls
119 | - **Comprehensive content**: Includes pages, blocks, databases, comments, and detailed property information
120 | - **Customizable depth**: Control the level of recursion to balance between detail and performance
121 |
122 | ### Using One Pager Tool
123 |
124 | ```
125 | {
126 | "page_id": "YOUR_PAGE_ID",
127 | "maxDepth": 5, // Optional: Maximum recursion depth (default: 5)
128 | "includeDatabases": true, // Optional: Include linked databases (default: true)
129 | "includeComments": true, // Optional: Include comments (default: true)
130 | "includeProperties": true // Optional: Include detailed page properties (default: true)
131 | }
132 | ```
133 |
134 | This automatic exploration capability is especially useful for AI assistants that need to understand the entire content of a Notion page without making dozens of separate API calls, resulting in much faster and more efficient responses.
135 |
136 | ## Asynchronous Processing
137 |
138 | The server implements advanced parallel processing techniques for handling large Notion documents:
139 |
140 | - Multiple requests are batched and processed concurrently
141 | - Pagination is handled automatically for block children
142 | - Results are efficiently aggregated before being returned
143 | - Console logging provides visibility into the process without affecting response format
144 |
145 | ## Examples
146 |
147 | 1. Using the following instruction:
148 |
149 | ```
150 | Get the content of page 1a6b35e6e67f802fa7e1d27686f017f2
151 | ```
152 |
153 | The AI will retrieve the page details efficiently with parallel processing of block content.
154 |
155 | 2. Using database information:
156 |
157 | ```
158 | Get the structure of database 8a6b35e6e67f802fa7e1d27686f017f2
159 | ```
160 |
161 | ## Development
162 |
163 | Build:
164 |
165 | ```
166 | pnpm build
167 | ```
168 |
169 | Execute:
170 |
171 | ```
172 | pnpm dev
173 | ```
174 |
175 | ## License
176 |
177 | MIT
178 |
179 | ## AI Assistant Performance Benefits
180 |
181 | Modern AI assistants like Cursor and Claude have limitations on the number of tools they can effectively handle:
182 |
183 | - Most models may not respect more than 40 tools in total
184 | - Too many tools can degrade overall performance and reasoning capabilities
185 | - Complex tool sets increase response latency and decision-making difficulty
186 |
187 | This read-only implementation deliberately reduces the Notion API surface to address these limitations while preserving all essential functionality. The result is:
188 |
189 | - Faster and more reliable responses from AI assistants
190 | - Improved accuracy when interacting with Notion content
191 | - Better overall performance through focused API design
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/auth/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | export * from './types'
2 | export * from './template'
3 |
```
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
```yaml
1 | services:
2 | notion-mcp-server:
3 | build: .
4 | stdin_open: true
5 | tty: true
6 | restart: unless-stopped
7 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | export { OpenAPIToMCPConverter } from './openapi/parser'
2 | export { HttpClient } from './client/http-client'
3 | export type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
4 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/auth/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
2 |
3 | export interface AuthTemplate {
4 | url: string
5 | method: HttpMethod
6 | headers: Record<string, string>
7 | body?: string
8 | }
9 |
10 | export interface SecurityScheme {
11 | [key: string]: {
12 | tokenUrl?: string
13 | [key: string]: any
14 | }
15 | }
16 |
17 | export interface Server {
18 | url: string
19 | description?: string
20 | }
21 |
22 | export interface TemplateContext {
23 | securityScheme?: SecurityScheme
24 | servers?: Server[]
25 | args: Record<string, string>
26 | }
27 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "declaration": true,
5 | "declarationMap": true,
6 | "sourceMap": true,
7 | "outDir": "./build",
8 | "target": "es2021",
9 | "lib": ["es2022"],
10 | "jsx": "react-jsx",
11 | "module": "es2022",
12 | "moduleResolution": "Bundler",
13 | "types": [
14 | "node"
15 | ],
16 | "resolveJsonModule": true,
17 | "allowJs": true,
18 | "checkJs": false,
19 | "isolatedModules": true,
20 | "allowSyntheticDefaultImports": true,
21 | "forceConsistentCasingInFileNames": true,
22 | "strict": true,
23 | "skipLibCheck": true
24 | },
25 | "include": [ "test/**/*.ts", "scripts/**/*.ts", "src/**/*.ts", "examples/**/*.cjs"]
26 | }
27 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/auth/template.ts:
--------------------------------------------------------------------------------
```typescript
1 | import Mustache from 'mustache'
2 | import { AuthTemplate, TemplateContext } from './types'
3 |
4 | export function renderAuthTemplate(template: AuthTemplate, context: TemplateContext): AuthTemplate {
5 | // Disable HTML escaping for URLs
6 | Mustache.escape = (text) => text
7 |
8 | // Render URL with template variables
9 | const renderedUrl = Mustache.render(template.url, context)
10 |
11 | // Create a new template object with rendered values
12 | const renderedTemplate: AuthTemplate = {
13 | ...template,
14 | url: renderedUrl,
15 | headers: { ...template.headers }, // Create a new headers object to avoid modifying the original
16 | }
17 |
18 | // Render body if it exists
19 | if (template.body) {
20 | renderedTemplate.body = Mustache.render(template.body, context)
21 | }
22 |
23 | return renderedTemplate
24 | }
25 |
```
--------------------------------------------------------------------------------
/scripts/build-cli.js:
--------------------------------------------------------------------------------
```javascript
1 | import * as esbuild from 'esbuild';
2 | import { chmod } from 'fs/promises';
3 | import { fileURLToPath } from 'url';
4 | import { dirname, join } from 'path';
5 |
6 | const __dirname = dirname(fileURLToPath(import.meta.url));
7 |
8 | async function build() {
9 | await esbuild.build({
10 | entryPoints: [join(__dirname, 'start-server.ts')],
11 | bundle: true,
12 | minify: true,
13 | platform: 'node',
14 | target: 'node18',
15 | format: 'esm',
16 | outfile: 'bin/cli.mjs',
17 | banner: {
18 | js: "#!/usr/bin/env node\nimport { createRequire } from 'module';const require = createRequire(import.meta.url);" // see https://github.com/evanw/esbuild/pull/2067
19 | },
20 | external: ['util'],
21 | });
22 |
23 | // Make the output file executable
24 | await chmod('./bin/cli.mjs', 0o755);
25 | }
26 |
27 | build().catch((err) => {
28 | console.error(err);
29 | process.exit(1);
30 | });
31 |
```
--------------------------------------------------------------------------------
/scripts/start-server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
2 | import path from 'node:path'
3 | import { fileURLToPath } from 'url'
4 |
5 | import { initProxy, ValidationError } from '../src/init-server'
6 |
7 | export async function startServer(args: string[] = process.argv.slice(2)) {
8 | const filename = fileURLToPath(import.meta.url)
9 | const directory = path.dirname(filename)
10 | const specPath = path.resolve(directory, '../scripts/notion-openapi.json')
11 |
12 | const baseUrl = process.env.BASE_URL ?? undefined
13 |
14 | const proxy = await initProxy(specPath, baseUrl)
15 | await proxy.connect(new StdioServerTransport())
16 |
17 | return proxy.getServer()
18 | }
19 |
20 | startServer().catch(error => {
21 | if (error instanceof ValidationError) {
22 | console.error('Invalid OpenAPI 3.1 specification:')
23 | error.errors.forEach(err => console.error(err))
24 | } else {
25 | console.error('Error:', error)
26 | }
27 | process.exit(1)
28 | })
29 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # syntax=docker/dockerfile:1
2 |
3 | # Use Node.js LTS as the base image
4 | FROM node:20-slim AS builder
5 |
6 | # Set working directory
7 | WORKDIR /app
8 |
9 | # Copy package.json and package-lock.json
10 | COPY package*.json ./
11 |
12 | # Install dependencies
13 | RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts --omit-dev
14 |
15 | # Copy source code
16 | COPY . .
17 |
18 | # Build the package
19 | RUN --mount=type=cache,target=/root/.npm npm run build
20 |
21 | # Install package globally
22 | RUN --mount=type=cache,target=/root/.npm npm link
23 |
24 | # Minimal image for runtime
25 | FROM node:20-slim
26 |
27 | # Copy built package from builder stage
28 | COPY scripts/notion-openapi.json /usr/local/scripts/
29 | COPY --from=builder /usr/local/lib/node_modules/@notionhq/notion-mcp-server /usr/local/lib/node_modules/@notionhq/notion-mcp-server
30 | COPY --from=builder /usr/local/bin/notion-mcp-server /usr/local/bin/notion-mcp-server
31 |
32 | # Set default environment variables
33 | ENV OPENAPI_MCP_HEADERS="{}"
34 |
35 | # Set entrypoint
36 | ENTRYPOINT ["notion-mcp-server"]
```
--------------------------------------------------------------------------------
/src/init-server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fs from 'node:fs'
2 | import path from 'node:path'
3 |
4 | import { OpenAPIV3 } from 'openapi-types'
5 | import OpenAPISchemaValidator from 'openapi-schema-validator'
6 |
7 | import { MCPProxy } from './openapi-mcp-server/mcp/proxy'
8 |
9 | export class ValidationError extends Error {
10 | constructor(public errors: any[]) {
11 | super('OpenAPI validation failed')
12 | this.name = 'ValidationError'
13 | }
14 | }
15 |
16 | async function loadOpenApiSpec(specPath: string, baseUrl: string | undefined): Promise<OpenAPIV3.Document> {
17 | let rawSpec: string
18 |
19 | try {
20 | rawSpec = fs.readFileSync(path.resolve(process.cwd(), specPath), 'utf-8')
21 | } catch (error) {
22 | console.error('Failed to read OpenAPI specification file:', (error as Error).message)
23 | process.exit(1)
24 | }
25 |
26 | // Parse and validate the OpenApi Spec
27 | try {
28 | const parsed = JSON.parse(rawSpec)
29 |
30 | // Override baseUrl if specified.
31 | if (baseUrl) {
32 | parsed.servers[0].url = baseUrl
33 | }
34 |
35 | return parsed as OpenAPIV3.Document
36 | } catch (error) {
37 | if (error instanceof ValidationError) {
38 | throw error
39 | }
40 | console.error('Failed to parse OpenAPI spec:', (error as Error).message)
41 | process.exit(1)
42 | }
43 | }
44 |
45 | export async function initProxy(specPath: string, baseUrl: string |undefined) {
46 | const openApiSpec = await loadOpenApiSpec(specPath, baseUrl)
47 | const proxy = new MCPProxy('Notion API', openApiSpec)
48 |
49 | return proxy
50 | }
51 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/file-upload.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { OpenAPIV3 } from 'openapi-types'
2 |
3 | /**
4 | * Identifies file upload parameters in an OpenAPI operation
5 | * @param operation The OpenAPI operation object to check
6 | * @returns Array of parameter names that are file uploads
7 | */
8 | export function isFileUploadParameter(operation: OpenAPIV3.OperationObject): string[] {
9 | const fileParams: string[] = []
10 |
11 | if (!operation.requestBody) return fileParams
12 |
13 | const requestBody = operation.requestBody as OpenAPIV3.RequestBodyObject
14 | const content = requestBody.content || {}
15 |
16 | // Check multipart/form-data content type for file uploads
17 | const multipartContent = content['multipart/form-data']
18 | if (!multipartContent?.schema) return fileParams
19 |
20 | const schema = multipartContent.schema as OpenAPIV3.SchemaObject
21 | if (schema.type !== 'object' || !schema.properties) return fileParams
22 |
23 | // Look for properties with type: string, format: binary which indicates file uploads
24 | Object.entries(schema.properties).forEach(([propName, prop]) => {
25 | const schemaProp = prop as OpenAPIV3.SchemaObject
26 | if (schemaProp.type === 'string' && schemaProp.format === 'binary') {
27 | fileParams.push(propName)
28 | }
29 |
30 | // Check for array of files
31 | if (schemaProp.type === 'array' && schemaProp.items) {
32 | const itemSchema = schemaProp.items as OpenAPIV3.SchemaObject
33 | if (itemSchema.type === 'string' && itemSchema.format === 'binary') {
34 | fileParams.push(propName)
35 | }
36 | }
37 | })
38 |
39 | return fileParams
40 | }
41 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "notion-readonly-mcp-server",
3 | "keywords": [
4 | "notion",
5 | "api",
6 | "mcp",
7 | "server",
8 | "read-only",
9 | "async"
10 | ],
11 | "version": "1.0.9",
12 | "license": "MIT",
13 | "type": "module",
14 | "scripts": {
15 | "build": "tsc -build && node scripts/build-cli.js",
16 | "dev": "tsx watch scripts/start-server.ts"
17 | },
18 | "bin": {
19 | "notion-mcp-server": "bin/cli.mjs"
20 | },
21 | "dependencies": {
22 | "@modelcontextprotocol/sdk": "^1.8.0",
23 | "axios": "^1.8.4",
24 | "form-data": "^4.0.1",
25 | "mustache": "^4.2.0",
26 | "openapi-client-axios": "^7.5.5",
27 | "openapi-schema-validator": "^12.1.3",
28 | "openapi-types": "^12.1.3",
29 | "which": "^5.0.0",
30 | "zod": "3.24.1"
31 | },
32 | "devDependencies": {
33 | "@anthropic-ai/sdk": "^0.33.1",
34 | "@types/express": "^5.0.0",
35 | "@types/js-yaml": "^4.0.9",
36 | "@types/json-schema": "^7.0.15",
37 | "@types/mustache": "^4.2.5",
38 | "@types/node": "^20.17.16",
39 | "@types/which": "^3.0.4",
40 | "@vitest/coverage-v8": "3.1.1",
41 | "body-parser": "^2.2.0",
42 | "esbuild": "^0.25.2",
43 | "express": "^4.21.2",
44 | "multer": "1.4.5-lts.1",
45 | "openai": "^4.91.1",
46 | "tsx": "^4.19.3",
47 | "typescript": "^5.8.2",
48 | "vitest": "^3.1.1"
49 | },
50 | "description": "Optimized read-only MCP server for Notion API with asynchronous processing",
51 | "main": "index.js",
52 | "repository": {
53 | "type": "git",
54 | "url": "[email protected]:Taewoong1378/notion-readonly-mcp-server.git"
55 | },
56 | "author": "@taewoong1378",
57 | "bugs": {
58 | "url": "https://github.com/Taewoong1378/notion-readonly-mcp-server/issues"
59 | },
60 | "homepage": "https://github.com/Taewoong1378/notion-readonly-mcp-server#readme"
61 | }
62 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/__tests__/file-upload.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { OpenAPIV3 } from 'openapi-types'
2 | import { describe, it, expect } from 'vitest'
3 | import { isFileUploadParameter } from '../file-upload'
4 |
5 | describe('File Upload Detection', () => {
6 | it('identifies file upload parameters in request bodies', () => {
7 | const operation: OpenAPIV3.OperationObject = {
8 | operationId: 'uploadFile',
9 | responses: {
10 | '200': {
11 | description: 'File uploaded successfully',
12 | },
13 | },
14 | requestBody: {
15 | content: {
16 | 'multipart/form-data': {
17 | schema: {
18 | type: 'object',
19 | properties: {
20 | file: {
21 | type: 'string',
22 | format: 'binary',
23 | },
24 | additionalInfo: {
25 | type: 'string',
26 | },
27 | },
28 | },
29 | },
30 | },
31 | },
32 | }
33 |
34 | const fileParams = isFileUploadParameter(operation)
35 | expect(fileParams).toEqual(['file'])
36 | })
37 |
38 | it('returns empty array for non-file upload operations', () => {
39 | const operation: OpenAPIV3.OperationObject = {
40 | operationId: 'createUser',
41 | responses: {
42 | '200': {
43 | description: 'User created successfully',
44 | },
45 | },
46 | requestBody: {
47 | content: {
48 | 'application/json': {
49 | schema: {
50 | type: 'object',
51 | properties: {
52 | name: {
53 | type: 'string',
54 | },
55 | },
56 | },
57 | },
58 | },
59 | },
60 | }
61 |
62 | const fileParams = isFileUploadParameter(operation)
63 | expect(fileParams).toEqual([])
64 | })
65 |
66 | it('identifies array-based file upload parameters', () => {
67 | const operation: OpenAPIV3.OperationObject = {
68 | operationId: 'uploadFiles',
69 | responses: {
70 | '200': {
71 | description: 'Files uploaded successfully',
72 | },
73 | },
74 | requestBody: {
75 | content: {
76 | 'multipart/form-data': {
77 | schema: {
78 | type: 'object',
79 | properties: {
80 | files: {
81 | type: 'array',
82 | items: {
83 | type: 'string',
84 | format: 'binary',
85 | },
86 | },
87 | description: {
88 | type: 'string',
89 | },
90 | },
91 | },
92 | },
93 | },
94 | },
95 | }
96 |
97 | const fileParams = isFileUploadParameter(operation)
98 | expect(fileParams).toEqual(['files'])
99 | })
100 | })
101 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/__tests__/http-client-upload.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fs from 'fs'
2 | import { OpenAPIV3 } from 'openapi-types'
3 | import { beforeEach, describe, expect, it, vi } from 'vitest'
4 | import { HttpClient } from '../http-client'
5 |
6 | // 모킹 방식 변경
7 | vi.mock('fs', () => {
8 | return {
9 | default: {
10 | createReadStream: vi.fn()
11 | },
12 | createReadStream: vi.fn()
13 | }
14 | })
15 |
16 | vi.mock('form-data', () => {
17 | const FormDataMock = vi.fn().mockImplementation(() => ({
18 | append: vi.fn(),
19 | getHeaders: vi.fn().mockReturnValue({ 'content-type': 'multipart/form-data; boundary=---123' })
20 | }))
21 | return {
22 | default: FormDataMock
23 | }
24 | })
25 |
26 | describe('HttpClient File Upload', () => {
27 | let client: HttpClient
28 | const mockApiInstance = {
29 | uploadFile: vi.fn(),
30 | }
31 |
32 | const baseConfig = {
33 | baseUrl: 'http://test.com',
34 | headers: {},
35 | }
36 |
37 | const mockOpenApiSpec: OpenAPIV3.Document = {
38 | openapi: '3.0.0',
39 | info: {
40 | title: 'Test API',
41 | version: '1.0.0',
42 | },
43 | paths: {
44 | '/upload': {
45 | post: {
46 | operationId: 'uploadFile',
47 | responses: {
48 | '200': {
49 | description: 'File uploaded successfully',
50 | content: {
51 | 'application/json': {
52 | schema: {
53 | type: 'object',
54 | properties: {
55 | success: {
56 | type: 'boolean',
57 | },
58 | },
59 | },
60 | },
61 | },
62 | },
63 | },
64 | requestBody: {
65 | content: {
66 | 'multipart/form-data': {
67 | schema: {
68 | type: 'object',
69 | properties: {
70 | file: {
71 | type: 'string',
72 | format: 'binary',
73 | },
74 | description: {
75 | type: 'string',
76 | },
77 | },
78 | },
79 | },
80 | },
81 | },
82 | },
83 | },
84 | },
85 | }
86 |
87 | beforeEach(() => {
88 | vi.clearAllMocks()
89 | client = new HttpClient(baseConfig, mockOpenApiSpec)
90 | // @ts-expect-error - Mock the private api property
91 | client['api'] = Promise.resolve(mockApiInstance)
92 | })
93 |
94 | it('should handle file uploads with FormData', async () => {
95 | const mockFileStream = { pipe: vi.fn() }
96 |
97 | // 모킹 방식 변경
98 | vi.mocked(fs.createReadStream).mockReturnValue(mockFileStream as any)
99 |
100 | const uploadPath = mockOpenApiSpec.paths['/upload']
101 | if (!uploadPath?.post) {
102 | throw new Error('Upload path not found in spec')
103 | }
104 | const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string }
105 | const params = {
106 | file: '/path/to/test.txt',
107 | description: 'Test file',
108 | }
109 |
110 | mockApiInstance.uploadFile.mockResolvedValue({
111 | data: { success: true },
112 | status: 200,
113 | headers: {},
114 | })
115 |
116 | await client.executeOperation(operation, params)
117 |
118 | expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test.txt')
119 | expect(mockApiInstance.uploadFile).toHaveBeenCalled()
120 | })
121 |
122 | it('should throw error for invalid file path', async () => {
123 | vi.mocked(fs.createReadStream).mockImplementation(() => {
124 | throw new Error('File not found')
125 | })
126 |
127 | const uploadPath = mockOpenApiSpec.paths['/upload']
128 | if (!uploadPath?.post) {
129 | throw new Error('Upload path not found in spec')
130 | }
131 | const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string }
132 | const params = {
133 | file: '/nonexistent/file.txt',
134 | description: 'Test file',
135 | }
136 |
137 | await expect(client.executeOperation(operation, params)).rejects.toThrow('Failed to read file at /nonexistent/file.txt')
138 | })
139 |
140 | it('should handle multiple file uploads', async () => {
141 | const mockFileStream1 = { pipe: vi.fn() }
142 | const mockFileStream2 = { pipe: vi.fn() }
143 |
144 | // createReadStream 모킹을 시퀀스로 설정
145 | vi.mocked(fs.createReadStream)
146 | .mockReturnValueOnce(mockFileStream1 as any)
147 | .mockReturnValueOnce(mockFileStream2 as any)
148 |
149 | const operation: OpenAPIV3.OperationObject = {
150 | operationId: 'uploadFile',
151 | responses: {
152 | '200': {
153 | description: 'Files uploaded successfully',
154 | content: {
155 | 'application/json': {
156 | schema: {
157 | type: 'object',
158 | properties: {
159 | success: {
160 | type: 'boolean',
161 | },
162 | },
163 | },
164 | },
165 | },
166 | },
167 | },
168 | requestBody: {
169 | content: {
170 | 'multipart/form-data': {
171 | schema: {
172 | type: 'object',
173 | properties: {
174 | file1: {
175 | type: 'string',
176 | format: 'binary',
177 | },
178 | file2: {
179 | type: 'string',
180 | format: 'binary',
181 | },
182 | description: {
183 | type: 'string',
184 | },
185 | },
186 | },
187 | },
188 | },
189 | },
190 | }
191 |
192 | const params = {
193 | file1: '/path/to/test1.txt',
194 | file2: '/path/to/test2.txt',
195 | description: 'Test files',
196 | }
197 |
198 | mockApiInstance.uploadFile.mockResolvedValue({
199 | data: { success: true },
200 | status: 200,
201 | headers: {},
202 | })
203 |
204 | await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, params)
205 |
206 | expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test1.txt')
207 | expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test2.txt')
208 | expect(mockApiInstance.uploadFile).toHaveBeenCalled()
209 | })
210 | })
211 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type express from 'express'
2 | import { afterAll, beforeAll, describe, expect, it } from 'vitest'
3 | import { HttpClient } from '../http-client'
4 | //@ts-ignore
5 | import axios from 'axios'
6 | import type { OpenAPIV3 } from 'openapi-types'
7 | import { createPetstoreServer } from '../../../../examples/petstore-server.cjs'
8 |
9 | interface Pet {
10 | id: number
11 | name: string
12 | species: string
13 | age: number
14 | status: 'available' | 'pending' | 'sold'
15 | }
16 |
17 | describe('HttpClient Integration Tests', () => {
18 | const PORT = 3456
19 | const BASE_URL = `http://localhost:${PORT}`
20 | let server: ReturnType<typeof express>
21 | let openApiSpec: OpenAPIV3.Document
22 | let client: HttpClient
23 |
24 | beforeAll(async () => {
25 | // Start the petstore server
26 | server = createPetstoreServer(PORT) as unknown as express.Express
27 |
28 | // Fetch the OpenAPI spec from the server
29 | const response = await axios.get(`${BASE_URL}/openapi.json`)
30 | openApiSpec = response.data
31 |
32 | // Create HTTP client
33 | client = new HttpClient(
34 | {
35 | baseUrl: BASE_URL,
36 | headers: {
37 | Accept: 'application/json',
38 | },
39 | },
40 | openApiSpec,
41 | )
42 | })
43 |
44 | afterAll(() => {
45 | //@ts-expect-error
46 | server.close()
47 | })
48 |
49 | it('should list all pets', async () => {
50 | const operation = openApiSpec.paths['/pets']?.get
51 | if (!operation) throw new Error('Operation not found')
52 |
53 | const response = await client.executeOperation<Pet[]>(operation as OpenAPIV3.OperationObject & { method: string; path: string })
54 |
55 | expect(response.status).toBe(200)
56 | expect(Array.isArray(response.data)).toBe(true)
57 | expect(response.data.length).toBeGreaterThan(0)
58 | expect(response.data[0]).toHaveProperty('name')
59 | expect(response.data[0]).toHaveProperty('species')
60 | expect(response.data[0]).toHaveProperty('status')
61 | })
62 |
63 | it('should filter pets by status', async () => {
64 | const operation = openApiSpec.paths['/pets']?.get as OpenAPIV3.OperationObject & { method: string; path: string }
65 | if (!operation) throw new Error('Operation not found')
66 |
67 | const response = await client.executeOperation<Pet[]>(operation, { status: 'available' })
68 |
69 | expect(response.status).toBe(200)
70 | expect(Array.isArray(response.data)).toBe(true)
71 | response.data.forEach((pet: Pet) => {
72 | expect(pet.status).toBe('available')
73 | })
74 | })
75 |
76 | it('should get a specific pet by ID', async () => {
77 | const operation = openApiSpec.paths['/pets/{id}']?.get as OpenAPIV3.OperationObject & { method: string; path: string }
78 | if (!operation) throw new Error('Operation not found')
79 |
80 | const response = await client.executeOperation<Pet>(operation, { id: 1 })
81 |
82 | expect(response.status).toBe(200)
83 | expect(response.data).toHaveProperty('id', 1)
84 | expect(response.data).toHaveProperty('name')
85 | expect(response.data).toHaveProperty('species')
86 | })
87 |
88 | it('should create a new pet', async () => {
89 | const operation = openApiSpec.paths['/pets']?.post as OpenAPIV3.OperationObject & { method: string; path: string }
90 | if (!operation) throw new Error('Operation not found')
91 |
92 | const newPet = {
93 | name: 'TestPet',
94 | species: 'Dog',
95 | age: 2,
96 | }
97 |
98 | const response = await client.executeOperation<Pet>(operation as OpenAPIV3.OperationObject & { method: string; path: string }, newPet)
99 |
100 | expect(response.status).toBe(201)
101 | expect(response.data).toMatchObject({
102 | ...newPet,
103 | status: 'available',
104 | })
105 | expect(response.data.id).toBeDefined()
106 | })
107 |
108 | it("should update a pet's status", async () => {
109 | const operation = openApiSpec.paths['/pets/{id}']?.put
110 | if (!operation) throw new Error('Operation not found')
111 |
112 | const response = await client.executeOperation<Pet>(operation as OpenAPIV3.OperationObject & { method: string; path: string }, {
113 | id: 1,
114 | status: 'sold',
115 | })
116 |
117 | expect(response.status).toBe(200)
118 | expect(response.data).toHaveProperty('id', 1)
119 | expect(response.data).toHaveProperty('status', 'sold')
120 | })
121 |
122 | it('should delete a pet', async () => {
123 | // First create a pet to delete
124 | const createOperation = openApiSpec.paths['/pets']?.post
125 | if (!createOperation) throw new Error('Operation not found')
126 |
127 | const createResponse = await client.executeOperation<Pet>(
128 | createOperation as OpenAPIV3.OperationObject & { method: string; path: string },
129 | {
130 | name: 'ToDelete',
131 | species: 'Cat',
132 | age: 3,
133 | },
134 | )
135 | const petId = createResponse.data.id
136 |
137 | // Then delete it
138 | const deleteOperation = openApiSpec.paths['/pets/{id}']?.delete
139 | if (!deleteOperation) throw new Error('Operation not found')
140 |
141 | const deleteResponse = await client.executeOperation(deleteOperation as OpenAPIV3.OperationObject & { method: string; path: string }, {
142 | id: petId,
143 | })
144 |
145 | expect(deleteResponse.status).toBe(204)
146 |
147 | // Verify the pet is deleted
148 | const getOperation = openApiSpec.paths['/pets/{id}']?.get
149 | if (!getOperation) throw new Error('Operation not found')
150 |
151 | try {
152 | await client.executeOperation(getOperation as OpenAPIV3.OperationObject & { method: string; path: string }, { id: petId })
153 | throw new Error('Should not reach here')
154 | } catch (error: any) {
155 | expect(error.message).toContain('404')
156 | }
157 | })
158 |
159 | it('should handle errors appropriately', async () => {
160 | const operation = openApiSpec.paths['/pets/{id}']?.get as OpenAPIV3.OperationObject & { method: string; path: string }
161 | if (!operation) throw new Error('Operation not found')
162 |
163 | try {
164 | await client.executeOperation(
165 | operation as OpenAPIV3.OperationObject & { method: string; path: string },
166 | { id: 99999 }, // Non-existent ID
167 | )
168 | throw new Error('Should not reach here')
169 | } catch (error: any) {
170 | expect(error.message).toContain('404')
171 | }
172 | })
173 | })
174 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/http-client.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
2 | import OpenAPIClientAxios from 'openapi-client-axios'
3 | import type { AxiosInstance } from 'axios'
4 | import FormData from 'form-data'
5 | import fs from 'fs'
6 | import { isFileUploadParameter } from '../openapi/file-upload'
7 |
8 | export type HttpClientConfig = {
9 | baseUrl: string
10 | headers?: Record<string, string>
11 | }
12 |
13 | export type HttpClientResponse<T = any> = {
14 | data: T
15 | status: number
16 | headers: Headers
17 | }
18 |
19 | export class HttpClientError extends Error {
20 | constructor(
21 | message: string,
22 | public status: number,
23 | public data: any,
24 | public headers?: Headers,
25 | ) {
26 | super(`${status} ${message}`)
27 | this.name = 'HttpClientError'
28 | }
29 | }
30 |
31 | export class HttpClient {
32 | private api: Promise<AxiosInstance>
33 | private client: OpenAPIClientAxios
34 |
35 | constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {
36 | // @ts-expect-error
37 | this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({
38 | definition: openApiSpec,
39 | axiosConfigDefaults: {
40 | baseURL: config.baseUrl,
41 | headers: {
42 | 'Content-Type': 'application/json',
43 | 'User-Agent': 'notion-mcp-server',
44 | ...config.headers,
45 | },
46 | },
47 | })
48 | this.api = this.client.init()
49 | }
50 |
51 | private async prepareFileUpload(operation: OpenAPIV3.OperationObject, params: Record<string, any>): Promise<FormData | null> {
52 | const fileParams = isFileUploadParameter(operation)
53 | if (fileParams.length === 0) return null
54 |
55 | const formData = new FormData()
56 |
57 | // Handle file uploads
58 | for (const param of fileParams) {
59 | const filePath = params[param]
60 | if (!filePath) {
61 | throw new Error(`File path must be provided for parameter: ${param}`)
62 | }
63 | switch (typeof filePath) {
64 | case 'string':
65 | addFile(param, filePath)
66 | break
67 | case 'object':
68 | if(Array.isArray(filePath)) {
69 | let fileCount = 0
70 | for(const file of filePath) {
71 | addFile(param, file)
72 | fileCount++
73 | }
74 | break
75 | }
76 | //deliberate fallthrough
77 | default:
78 | throw new Error(`Unsupported file type: ${typeof filePath}`)
79 | }
80 | function addFile(name: string, filePath: string) {
81 | try {
82 | const fileStream = fs.createReadStream(filePath)
83 | formData.append(name, fileStream)
84 | } catch (error) {
85 | throw new Error(`Failed to read file at ${filePath}: ${error}`)
86 | }
87 | }
88 | }
89 |
90 | // Add non-file parameters to form data
91 | for (const [key, value] of Object.entries(params)) {
92 | if (!fileParams.includes(key)) {
93 | formData.append(key, value)
94 | }
95 | }
96 |
97 | return formData
98 | }
99 |
100 | /**
101 | * Execute an OpenAPI operation
102 | */
103 | async executeOperation<T = any>(
104 | operation: OpenAPIV3.OperationObject & { method: string; path: string },
105 | params: Record<string, any> = {},
106 | ): Promise<HttpClientResponse<T>> {
107 | const api = await this.api
108 | const operationId = operation.operationId
109 | if (!operationId) {
110 | throw new Error('Operation ID is required')
111 | }
112 |
113 | // Handle file uploads if present
114 | const formData = await this.prepareFileUpload(operation, params)
115 |
116 | // Separate parameters based on their location
117 | const urlParameters: Record<string, any> = {}
118 | const bodyParams: Record<string, any> = formData || { ...params }
119 |
120 | // Extract path and query parameters based on operation definition
121 | if (operation.parameters) {
122 | for (const param of operation.parameters) {
123 | if ('name' in param && param.name && param.in) {
124 | if (param.in === 'path' || param.in === 'query') {
125 | if (params[param.name] !== undefined) {
126 | urlParameters[param.name] = params[param.name]
127 | if (!formData) {
128 | delete bodyParams[param.name]
129 | }
130 | }
131 | }
132 | }
133 | }
134 | }
135 |
136 | // Add all parameters as url parameters if there is no requestBody defined
137 | if (!operation.requestBody && !formData) {
138 | for (const key in bodyParams) {
139 | if (bodyParams[key] !== undefined) {
140 | urlParameters[key] = bodyParams[key]
141 | delete bodyParams[key]
142 | }
143 | }
144 | }
145 |
146 | const operationFn = (api as any)[operationId]
147 | if (!operationFn) {
148 | throw new Error(`Operation ${operationId} not found`)
149 | }
150 |
151 | try {
152 | // If we have form data, we need to set the correct headers
153 | const hasBody = Object.keys(bodyParams).length > 0
154 | const headers = formData
155 | ? formData.getHeaders()
156 | : { ...(hasBody ? { 'Content-Type': 'application/json' } : { 'Content-Type': null }) }
157 | const requestConfig = {
158 | headers: {
159 | ...headers,
160 | },
161 | }
162 |
163 | // first argument is url parameters, second is body parameters
164 | const response = await operationFn(urlParameters, hasBody ? bodyParams : undefined, requestConfig)
165 |
166 | // Convert axios headers to Headers object
167 | const responseHeaders = new Headers()
168 | Object.entries(response.headers).forEach(([key, value]) => {
169 | if (value) responseHeaders.append(key, value.toString())
170 | })
171 |
172 | return {
173 | data: response.data,
174 | status: response.status,
175 | headers: responseHeaders,
176 | }
177 | } catch (error: any) {
178 | if (error.response) {
179 | console.error('Error in http client', error)
180 | const headers = new Headers()
181 | Object.entries(error.response.headers).forEach(([key, value]) => {
182 | if (value) headers.append(key, value.toString())
183 | })
184 |
185 | throw new HttpClientError(error.response.statusText || 'Request failed', error.response.status, error.response.data, headers)
186 | }
187 | throw error
188 | }
189 | }
190 | }
191 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
2 | import { OpenAPIV3 } from 'openapi-types'
3 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4 | import { HttpClient } from '../../client/http-client'
5 | import { MCPProxy } from '../proxy'
6 |
7 | // Mock the dependencies
8 | vi.mock('../../client/http-client')
9 | vi.mock('@modelcontextprotocol/sdk/server/index.js')
10 |
11 | describe('MCPProxy', () => {
12 | let proxy: MCPProxy
13 | let mockOpenApiSpec: OpenAPIV3.Document
14 |
15 | beforeEach(() => {
16 | // Reset all mocks
17 | vi.clearAllMocks()
18 |
19 | // Setup minimal OpenAPI spec for testing
20 | mockOpenApiSpec = {
21 | openapi: '3.0.0',
22 | servers: [{ url: 'http://localhost:3000' }],
23 | info: {
24 | title: 'Test API',
25 | version: '1.0.0',
26 | },
27 | paths: {
28 | '/test': {
29 | get: {
30 | operationId: 'getTest',
31 | responses: {
32 | '200': {
33 | description: 'Success',
34 | },
35 | },
36 | },
37 | },
38 | },
39 | }
40 |
41 | proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
42 | })
43 |
44 | describe('listTools handler', () => {
45 | it('should return converted tools from OpenAPI spec', async () => {
46 | const server = (proxy as any).server
47 | const listToolsHandler = server.setRequestHandler.mock.calls[0].filter((x: unknown) => typeof x === 'function')[0]
48 | const result = await listToolsHandler()
49 |
50 | expect(result).toHaveProperty('tools')
51 | expect(Array.isArray(result.tools)).toBe(true)
52 | })
53 |
54 | it('should truncate tool names exceeding 64 characters', async () => {
55 | // Setup OpenAPI spec with long tool names
56 | mockOpenApiSpec.paths = {
57 | '/test': {
58 | get: {
59 | operationId: 'a'.repeat(65),
60 | responses: {
61 | '200': {
62 | description: 'Success'
63 | }
64 | }
65 | }
66 | }
67 | }
68 | proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
69 | const server = (proxy as any).server
70 | const listToolsHandler = server.setRequestHandler.mock.calls[0].filter((x: unknown) => typeof x === 'function')[0];
71 | const result = await listToolsHandler()
72 |
73 | expect(result.tools[0].name.length).toBeLessThanOrEqual(64)
74 | })
75 | })
76 |
77 | describe('callTool handler', () => {
78 | it('should execute operation and return formatted response', async () => {
79 | // Mock HttpClient response
80 | const mockResponse = {
81 | data: { message: 'success' },
82 | status: 200,
83 | headers: new Headers({
84 | 'content-type': 'application/json',
85 | }),
86 | }
87 | ;(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse)
88 |
89 | // Set up the openApiLookup with our test operation
90 | ;(proxy as any).openApiLookup = {
91 | 'API-getTest': {
92 | operationId: 'getTest',
93 | responses: { '200': { description: 'Success' } },
94 | method: 'get',
95 | path: '/test',
96 | },
97 | }
98 |
99 | const server = (proxy as any).server
100 | const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
101 | const callToolHandler = handlers[1]
102 |
103 | const result = await callToolHandler({
104 | params: {
105 | name: 'API-getTest',
106 | arguments: {},
107 | },
108 | })
109 |
110 | expect(result).toEqual({
111 | content: [
112 | {
113 | type: 'text',
114 | text: JSON.stringify({ message: 'success' }),
115 | },
116 | ],
117 | })
118 | })
119 |
120 | it('should throw error for non-existent operation', async () => {
121 | const server = (proxy as any).server
122 | const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
123 | const callToolHandler = handlers[1]
124 |
125 | await expect(
126 | callToolHandler({
127 | params: {
128 | name: 'nonExistentMethod',
129 | arguments: {},
130 | },
131 | }),
132 | ).resolves.toEqual({
133 | content: [
134 | {
135 | type: 'text',
136 | text: JSON.stringify({
137 | status: 'error',
138 | message: 'Method nonExistentMethod not found.',
139 | code: 404
140 | }),
141 | },
142 | ],
143 | })
144 | })
145 |
146 | it('should handle tool names exceeding 64 characters', async () => {
147 | // Mock HttpClient response
148 | const mockResponse = {
149 | data: { message: 'success' },
150 | status: 200,
151 | headers: new Headers({
152 | 'content-type': 'application/json'
153 | })
154 | };
155 | (HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
156 |
157 | // Set up the openApiLookup with a long tool name
158 | const longToolName = 'a'.repeat(65)
159 | const truncatedToolName = longToolName.slice(0, 64)
160 | ;(proxy as any).openApiLookup = {
161 | [truncatedToolName]: {
162 | operationId: longToolName,
163 | responses: { '200': { description: 'Success' } },
164 | method: 'get',
165 | path: '/test'
166 | }
167 | };
168 |
169 | const server = (proxy as any).server;
170 | const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function');
171 | const callToolHandler = handlers[1];
172 |
173 | const result = await callToolHandler({
174 | params: {
175 | name: truncatedToolName,
176 | arguments: {}
177 | }
178 | })
179 |
180 | expect(result).toEqual({
181 | content: [
182 | {
183 | type: 'text',
184 | text: JSON.stringify({ message: 'success' })
185 | }
186 | ]
187 | })
188 | })
189 | })
190 |
191 | describe('getContentType', () => {
192 | it('should return correct content type for different headers', () => {
193 | const getContentType = (proxy as any).getContentType.bind(proxy)
194 |
195 | expect(getContentType(new Headers({ 'content-type': 'text/plain' }))).toBe('text')
196 | expect(getContentType(new Headers({ 'content-type': 'application/json' }))).toBe('text')
197 | expect(getContentType(new Headers({ 'content-type': 'image/jpeg' }))).toBe('image')
198 | expect(getContentType(new Headers({ 'content-type': 'application/octet-stream' }))).toBe('binary')
199 | expect(getContentType(new Headers())).toBe('binary')
200 | })
201 | })
202 |
203 | describe('parseHeadersFromEnv', () => {
204 | const originalEnv = process.env
205 |
206 | beforeEach(() => {
207 | process.env = { ...originalEnv }
208 | })
209 |
210 | afterEach(() => {
211 | process.env = originalEnv
212 | })
213 |
214 | it('should parse valid JSON headers from env', () => {
215 | process.env.OPENAPI_MCP_HEADERS = JSON.stringify({
216 | Authorization: 'Bearer token123',
217 | 'X-Custom-Header': 'test',
218 | })
219 |
220 | const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
221 | expect(HttpClient).toHaveBeenCalledWith(
222 | expect.objectContaining({
223 | headers: {
224 | Authorization: 'Bearer token123',
225 | 'X-Custom-Header': 'test',
226 | },
227 | }),
228 | expect.anything(),
229 | )
230 | })
231 |
232 | it('should return empty object when env var is not set', () => {
233 | delete process.env.OPENAPI_MCP_HEADERS
234 |
235 | const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
236 | expect(HttpClient).toHaveBeenCalledWith(
237 | expect.objectContaining({
238 | headers: {},
239 | }),
240 | expect.anything(),
241 | )
242 | })
243 |
244 | it('should return empty object and warn on invalid JSON', () => {
245 | const consoleSpy = vi.spyOn(console, 'warn')
246 | process.env.OPENAPI_MCP_HEADERS = 'invalid json'
247 |
248 | const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
249 | expect(HttpClient).toHaveBeenCalledWith(
250 | expect.objectContaining({
251 | headers: {},
252 | }),
253 | expect.anything(),
254 | )
255 | expect(consoleSpy).toHaveBeenCalledWith('Failed to parse OPENAPI_MCP_HEADERS environment variable:', expect.any(Error))
256 | })
257 |
258 | it('should return empty object and warn on non-object JSON', () => {
259 | const consoleSpy = vi.spyOn(console, 'warn')
260 | process.env.OPENAPI_MCP_HEADERS = '"string"'
261 |
262 | const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
263 | expect(HttpClient).toHaveBeenCalledWith(
264 | expect.objectContaining({
265 | headers: {},
266 | }),
267 | expect.anything(),
268 | )
269 | expect(consoleSpy).toHaveBeenCalledWith('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', 'string')
270 | })
271 | })
272 | describe('connect', () => {
273 | it('should connect to transport', async () => {
274 | const mockTransport = {} as Transport
275 | await proxy.connect(mockTransport)
276 |
277 | const server = (proxy as any).server
278 | expect(server.connect).toHaveBeenCalledWith(mockTransport)
279 | })
280 | })
281 | })
282 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/mcp/__tests__/one-pager.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { OpenAPIV3 } from 'openapi-types'
2 | import { beforeEach, describe, expect, it, vi } from 'vitest'
3 | import { HttpClient } from '../../client/http-client'
4 | import { MCPProxy } from '../proxy'
5 |
6 | // Mock the dependencies
7 | vi.mock('../../client/http-client')
8 | vi.mock('@modelcontextprotocol/sdk/server/index.js')
9 |
10 | describe('MCPProxy - One Pager Functionality', () => {
11 | let proxy: MCPProxy
12 | let mockOpenApiSpec: OpenAPIV3.Document
13 |
14 | beforeEach(() => {
15 | // Reset all mocks
16 | vi.clearAllMocks()
17 |
18 | // Setup OpenAPI spec for testing
19 | mockOpenApiSpec = {
20 | openapi: '3.0.0',
21 | servers: [{ url: 'http://localhost:3000' }],
22 | info: {
23 | title: 'Notion API',
24 | version: '1.0.0',
25 | },
26 | paths: {
27 | '/v1/pages/{page_id}': {
28 | get: {
29 | operationId: 'retrieve-a-page',
30 | parameters: [
31 | {
32 | name: 'page_id',
33 | in: 'path',
34 | required: true,
35 | schema: { type: 'string' }
36 | }
37 | ],
38 | responses: {
39 | '200': {
40 | description: 'Success',
41 | },
42 | },
43 | },
44 | },
45 | '/v1/blocks/{block_id}/children': {
46 | get: {
47 | operationId: 'get-block-children',
48 | parameters: [
49 | {
50 | name: 'block_id',
51 | in: 'path',
52 | required: true,
53 | schema: { type: 'string' }
54 | },
55 | {
56 | name: 'page_size',
57 | in: 'query',
58 | schema: { type: 'integer' }
59 | }
60 | ],
61 | responses: {
62 | '200': {
63 | description: 'Success',
64 | },
65 | },
66 | },
67 | },
68 | '/v1/blocks/{block_id}': {
69 | get: {
70 | operationId: 'retrieve-a-block',
71 | parameters: [
72 | {
73 | name: 'block_id',
74 | in: 'path',
75 | required: true,
76 | schema: { type: 'string' }
77 | }
78 | ],
79 | responses: {
80 | '200': {
81 | description: 'Success',
82 | },
83 | },
84 | },
85 | },
86 | '/v1/databases/{database_id}': {
87 | get: {
88 | operationId: 'retrieve-a-database',
89 | parameters: [
90 | {
91 | name: 'database_id',
92 | in: 'path',
93 | required: true,
94 | schema: { type: 'string' }
95 | }
96 | ],
97 | responses: {
98 | '200': {
99 | description: 'Success',
100 | },
101 | },
102 | },
103 | },
104 | '/v1/comments': {
105 | get: {
106 | operationId: 'retrieve-a-comment',
107 | parameters: [
108 | {
109 | name: 'block_id',
110 | in: 'query',
111 | required: true,
112 | schema: { type: 'string' }
113 | }
114 | ],
115 | responses: {
116 | '200': {
117 | description: 'Success',
118 | },
119 | },
120 | },
121 | },
122 | '/v1/pages/{page_id}/properties/{property_id}': {
123 | get: {
124 | operationId: 'retrieve-a-page-property',
125 | parameters: [
126 | {
127 | name: 'page_id',
128 | in: 'path',
129 | required: true,
130 | schema: { type: 'string' }
131 | },
132 | {
133 | name: 'property_id',
134 | in: 'path',
135 | required: true,
136 | schema: { type: 'string' }
137 | }
138 | ],
139 | responses: {
140 | '200': {
141 | description: 'Success',
142 | },
143 | },
144 | },
145 | },
146 | },
147 | }
148 |
149 | proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
150 | })
151 |
152 | describe('handleOnePagerRequest', () => {
153 | it('should recursively retrieve page content', async () => {
154 | // Set up mocks for each API response
155 |
156 | // 1. Mock page response
157 | const mockPageResponse = {
158 | data: {
159 | object: 'page',
160 | id: 'test-page-id',
161 | properties: {
162 | title: {
163 | id: 'title',
164 | type: 'title',
165 | title: [{ type: 'text', text: { content: 'Test Page' } }]
166 | }
167 | },
168 | has_children: true
169 | },
170 | status: 200,
171 | headers: new Headers({ 'content-type': 'application/json' }),
172 | }
173 |
174 | // 2. Mock block children response
175 | const mockBlocksResponse = {
176 | data: {
177 | object: 'list',
178 | results: [
179 | {
180 | object: 'block',
181 | id: 'block-1',
182 | type: 'paragraph',
183 | has_children: false,
184 | paragraph: {
185 | rich_text: [{ type: 'text', text: { content: 'Test paragraph' } }]
186 | }
187 | },
188 | {
189 | object: 'block',
190 | id: 'block-2',
191 | type: 'child_database',
192 | has_children: false,
193 | child_database: {
194 | database_id: 'db-1'
195 | }
196 | }
197 | ],
198 | next_cursor: null,
199 | has_more: false
200 | },
201 | status: 200,
202 | headers: new Headers({ 'content-type': 'application/json' }),
203 | }
204 |
205 | // 3. Mock database response
206 | const mockDatabaseResponse = {
207 | data: {
208 | object: 'database',
209 | id: 'db-1',
210 | title: [{ type: 'text', text: { content: 'Test Database' } }],
211 | properties: {
212 | Name: {
213 | id: 'title',
214 | type: 'title',
215 | title: {}
216 | }
217 | }
218 | },
219 | status: 200,
220 | headers: new Headers({ 'content-type': 'application/json' }),
221 | }
222 |
223 | // 4. Mock comments response
224 | const mockCommentsResponse = {
225 | data: {
226 | object: 'list',
227 | results: [
228 | {
229 | object: 'comment',
230 | id: 'comment-1',
231 | rich_text: [{ type: 'text', text: { content: 'Test comment' } }]
232 | }
233 | ],
234 | has_more: false
235 | },
236 | status: 200,
237 | headers: new Headers({ 'content-type': 'application/json' }),
238 | }
239 |
240 | // Set up the mock API responses
241 | const executeOperationMock = HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>
242 |
243 | executeOperationMock.mockImplementation((operation, params) => {
244 | if (operation.operationId === 'retrieve-a-page') {
245 | return Promise.resolve(mockPageResponse)
246 | } else if (operation.operationId === 'get-block-children') {
247 | return Promise.resolve(mockBlocksResponse)
248 | } else if (operation.operationId === 'retrieve-a-database') {
249 | return Promise.resolve(mockDatabaseResponse)
250 | } else if (operation.operationId === 'retrieve-a-comment') {
251 | return Promise.resolve(mockCommentsResponse)
252 | }
253 | return Promise.resolve({ data: {}, status: 200, headers: new Headers() })
254 | })
255 |
256 | // Set up openApiLookup with our test operations
257 | const openApiLookup = {
258 | 'API-retrieve-a-page': {
259 | operationId: 'retrieve-a-page',
260 | method: 'get',
261 | path: '/v1/pages/{page_id}',
262 | },
263 | 'API-get-block-children': {
264 | operationId: 'get-block-children',
265 | method: 'get',
266 | path: '/v1/blocks/{block_id}/children',
267 | },
268 | 'API-retrieve-a-database': {
269 | operationId: 'retrieve-a-database',
270 | method: 'get',
271 | path: '/v1/databases/{database_id}',
272 | },
273 | 'API-retrieve-a-comment': {
274 | operationId: 'retrieve-a-comment',
275 | method: 'get',
276 | path: '/v1/comments',
277 | },
278 | }
279 | ;(proxy as any).openApiLookup = openApiLookup
280 |
281 | // Get the server request handlers
282 | const server = (proxy as any).server
283 | const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
284 | const callToolHandler = handlers[1]
285 |
286 | // Call the get-one-pager tool
287 | const result = await callToolHandler({
288 | params: {
289 | name: 'API-get-one-pager',
290 | arguments: {
291 | page_id: 'test-page-id',
292 | maxDepth: 2,
293 | includeDatabases: true,
294 | includeComments: true
295 | },
296 | },
297 | })
298 |
299 | // Parse the result
300 | const onePagerData = JSON.parse(result.content[0].text)
301 |
302 | // Verify the structure of the One Pager result
303 | expect(onePagerData).toHaveProperty('id', 'test-page-id')
304 | expect(onePagerData).toHaveProperty('content')
305 |
306 | // Verify that recursive content was retrieved
307 | expect(onePagerData.content).toBeInstanceOf(Array)
308 | expect(onePagerData.content.length).toBeGreaterThan(0)
309 |
310 | // Verify that at least one comment was retrieved
311 | expect(onePagerData).toHaveProperty('comments')
312 | expect(onePagerData.comments.results.length).toBeGreaterThan(0)
313 |
314 | // Verify database information was retrieved
315 | const databaseBlock = onePagerData.content.find((block: any) => block.type === 'child_database')
316 | expect(databaseBlock).toBeDefined()
317 | expect(databaseBlock).toHaveProperty('database')
318 | expect(databaseBlock.database).toHaveProperty('id', 'db-1')
319 | })
320 | })
321 | })
```
--------------------------------------------------------------------------------
/examples/petstore-server.cjs:
--------------------------------------------------------------------------------
```
1 | const express = require('express')
2 | const bodyParser = require('body-parser')
3 |
4 | // 메모리에 저장할 데이터
5 | let pets = [
6 | {
7 | id: 1,
8 | name: 'Max',
9 | species: 'Dog',
10 | age: 3,
11 | status: 'available'
12 | },
13 | {
14 | id: 2,
15 | name: 'Whiskers',
16 | species: 'Cat',
17 | age: 2,
18 | status: 'pending'
19 | },
20 | {
21 | id: 3,
22 | name: 'Goldie',
23 | species: 'Fish',
24 | age: 1,
25 | status: 'sold'
26 | }
27 | ]
28 |
29 | // 다음 ID 추적용
30 | let nextId = 4
31 |
32 | /**
33 | * Petstore 서버 생성 함수
34 | * @param {number} port 서버가 실행될 포트
35 | * @returns {Express} Express 서버 인스턴스
36 | */
37 | function createPetstoreServer(port) {
38 | const app = express()
39 |
40 | // Middleware
41 | app.use(bodyParser.json())
42 |
43 | // OpenAPI spec 제공
44 | app.get('/openapi.json', (req, res) => {
45 | res.json({
46 | openapi: '3.0.0',
47 | info: {
48 | title: 'Petstore API',
49 | version: '1.0.0',
50 | description: 'A simple petstore API for testing'
51 | },
52 | servers: [
53 | {
54 | url: `http://localhost:${port}`
55 | }
56 | ],
57 | paths: {
58 | '/pets': {
59 | get: {
60 | operationId: 'listPets',
61 | summary: 'List all pets',
62 | parameters: [
63 | {
64 | name: 'status',
65 | in: 'query',
66 | required: false,
67 | schema: {
68 | type: 'string',
69 | enum: ['available', 'pending', 'sold']
70 | }
71 | }
72 | ],
73 | responses: {
74 | '200': {
75 | description: 'A list of pets',
76 | content: {
77 | 'application/json': {
78 | schema: {
79 | type: 'array',
80 | items: {
81 | $ref: '#/components/schemas/Pet'
82 | }
83 | }
84 | }
85 | }
86 | }
87 | }
88 | },
89 | post: {
90 | operationId: 'createPet',
91 | summary: 'Create a pet',
92 | requestBody: {
93 | content: {
94 | 'application/json': {
95 | schema: {
96 | $ref: '#/components/schemas/NewPet'
97 | }
98 | }
99 | },
100 | required: true
101 | },
102 | responses: {
103 | '201': {
104 | description: 'Pet created',
105 | content: {
106 | 'application/json': {
107 | schema: {
108 | $ref: '#/components/schemas/Pet'
109 | }
110 | }
111 | }
112 | }
113 | }
114 | }
115 | },
116 | '/pets/{id}': {
117 | get: {
118 | operationId: 'getPet',
119 | summary: 'Get a pet by ID',
120 | parameters: [
121 | {
122 | name: 'id',
123 | in: 'path',
124 | required: true,
125 | schema: {
126 | type: 'integer'
127 | }
128 | }
129 | ],
130 | responses: {
131 | '200': {
132 | description: 'A pet',
133 | content: {
134 | 'application/json': {
135 | schema: {
136 | $ref: '#/components/schemas/Pet'
137 | }
138 | }
139 | }
140 | },
141 | '404': {
142 | description: 'Pet not found',
143 | content: {
144 | 'application/json': {
145 | schema: {
146 | $ref: '#/components/schemas/Error'
147 | }
148 | }
149 | }
150 | }
151 | }
152 | },
153 | put: {
154 | operationId: 'updatePet',
155 | summary: 'Update a pet',
156 | parameters: [
157 | {
158 | name: 'id',
159 | in: 'path',
160 | required: true,
161 | schema: {
162 | type: 'integer'
163 | }
164 | }
165 | ],
166 | requestBody: {
167 | content: {
168 | 'application/json': {
169 | schema: {
170 | $ref: '#/components/schemas/PetUpdate'
171 | }
172 | }
173 | },
174 | required: true
175 | },
176 | responses: {
177 | '200': {
178 | description: 'Pet updated',
179 | content: {
180 | 'application/json': {
181 | schema: {
182 | $ref: '#/components/schemas/Pet'
183 | }
184 | }
185 | }
186 | },
187 | '404': {
188 | description: 'Pet not found',
189 | content: {
190 | 'application/json': {
191 | schema: {
192 | $ref: '#/components/schemas/Error'
193 | }
194 | }
195 | }
196 | }
197 | }
198 | },
199 | delete: {
200 | operationId: 'deletePet',
201 | summary: 'Delete a pet',
202 | parameters: [
203 | {
204 | name: 'id',
205 | in: 'path',
206 | required: true,
207 | schema: {
208 | type: 'integer'
209 | }
210 | }
211 | ],
212 | responses: {
213 | '204': {
214 | description: 'Pet deleted'
215 | },
216 | '404': {
217 | description: 'Pet not found',
218 | content: {
219 | 'application/json': {
220 | schema: {
221 | $ref: '#/components/schemas/Error'
222 | }
223 | }
224 | }
225 | }
226 | }
227 | }
228 | }
229 | },
230 | components: {
231 | schemas: {
232 | Pet: {
233 | type: 'object',
234 | required: ['id', 'name', 'species', 'status'],
235 | properties: {
236 | id: {
237 | type: 'integer'
238 | },
239 | name: {
240 | type: 'string'
241 | },
242 | species: {
243 | type: 'string'
244 | },
245 | age: {
246 | type: 'integer'
247 | },
248 | status: {
249 | type: 'string',
250 | enum: ['available', 'pending', 'sold']
251 | }
252 | }
253 | },
254 | NewPet: {
255 | type: 'object',
256 | required: ['name', 'species'],
257 | properties: {
258 | name: {
259 | type: 'string'
260 | },
261 | species: {
262 | type: 'string'
263 | },
264 | age: {
265 | type: 'integer'
266 | }
267 | }
268 | },
269 | PetUpdate: {
270 | type: 'object',
271 | properties: {
272 | name: {
273 | type: 'string'
274 | },
275 | species: {
276 | type: 'string'
277 | },
278 | age: {
279 | type: 'integer'
280 | },
281 | status: {
282 | type: 'string',
283 | enum: ['available', 'pending', 'sold']
284 | }
285 | }
286 | },
287 | Error: {
288 | type: 'object',
289 | required: ['code', 'message'],
290 | properties: {
291 | code: {
292 | type: 'string'
293 | },
294 | message: {
295 | type: 'string'
296 | }
297 | }
298 | }
299 | }
300 | }
301 | })
302 | })
303 |
304 | // 모든 펫 목록 조회
305 | app.get('/pets', (req, res) => {
306 | let result = [...pets]
307 |
308 | // 상태별 필터링
309 | if (req.query.status) {
310 | result = result.filter(pet => pet.status === req.query.status)
311 | }
312 |
313 | res.json(result)
314 | })
315 |
316 | // 특정 펫 조회
317 | app.get('/pets/:id', (req, res) => {
318 | const id = parseInt(req.params.id)
319 | const pet = pets.find(p => p.id === id)
320 |
321 | if (!pet) {
322 | return res.status(404).json({
323 | code: 'RESOURCE_NOT_FOUND',
324 | message: 'Pet not found',
325 | petId: id
326 | })
327 | }
328 |
329 | res.json(pet)
330 | })
331 |
332 | // 펫 생성
333 | app.post('/pets', (req, res) => {
334 | const { name, species, age } = req.body
335 |
336 | if (!name || !species) {
337 | return res.status(400).json({
338 | code: 'VALIDATION_ERROR',
339 | message: 'Name and species are required'
340 | })
341 | }
342 |
343 | const newPet = {
344 | id: nextId++,
345 | name,
346 | species,
347 | age: age || 0,
348 | status: 'available'
349 | }
350 |
351 | pets.push(newPet)
352 | res.status(201).json(newPet)
353 | })
354 |
355 | // 펫 정보 업데이트
356 | app.put('/pets/:id', (req, res) => {
357 | const id = parseInt(req.params.id)
358 | const petIndex = pets.findIndex(p => p.id === id)
359 |
360 | if (petIndex === -1) {
361 | return res.status(404).json({
362 | code: 'RESOURCE_NOT_FOUND',
363 | message: 'Pet not found',
364 | petId: id
365 | })
366 | }
367 |
368 | const { name, species, age, status } = req.body
369 | const updatedPet = {
370 | ...pets[petIndex],
371 | name: name !== undefined ? name : pets[petIndex].name,
372 | species: species !== undefined ? species : pets[petIndex].species,
373 | age: age !== undefined ? age : pets[petIndex].age,
374 | status: status !== undefined ? status : pets[petIndex].status
375 | }
376 |
377 | pets[petIndex] = updatedPet
378 | res.json(updatedPet)
379 | })
380 |
381 | // 펫 삭제
382 | app.delete('/pets/:id', (req, res) => {
383 | const id = parseInt(req.params.id)
384 | const petIndex = pets.findIndex(p => p.id === id)
385 |
386 | if (petIndex === -1) {
387 | return res.status(404).json({
388 | code: 'RESOURCE_NOT_FOUND',
389 | message: 'Pet not found',
390 | petId: id
391 | })
392 | }
393 |
394 | pets.splice(petIndex, 1)
395 | res.status(204).end()
396 | })
397 |
398 | // 서버 시작
399 | const server = app.listen(port, () => {
400 | console.log(`Petstore server running on http://localhost:${port}`)
401 | })
402 |
403 | return server
404 | }
405 |
406 | module.exports = { createPetstoreServer }
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/__tests__/http-client.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { HttpClient, HttpClientError } from '../http-client'
2 | import { OpenAPIV3 } from 'openapi-types'
3 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
4 | import OpenAPIClientAxios from 'openapi-client-axios'
5 |
6 | // Mock the OpenAPIClientAxios initialization
7 | vi.mock('openapi-client-axios', () => {
8 | const mockApi = {
9 | getPet: vi.fn(),
10 | testOperation: vi.fn(),
11 | complexOperation: vi.fn(),
12 | }
13 | return {
14 | default: vi.fn().mockImplementation(() => ({
15 | init: vi.fn().mockResolvedValue(mockApi),
16 | })),
17 | }
18 | })
19 |
20 | describe('HttpClient', () => {
21 | let client: HttpClient
22 | let mockApi: any
23 |
24 | const sampleSpec: OpenAPIV3.Document = {
25 | openapi: '3.0.0',
26 | info: { title: 'Test API', version: '1.0.0' },
27 | paths: {
28 | '/pets/{petId}': {
29 | get: {
30 | operationId: 'getPet',
31 | parameters: [
32 | {
33 | name: 'petId',
34 | in: 'path',
35 | required: true,
36 | schema: { type: 'integer' },
37 | },
38 | ],
39 | responses: {
40 | '200': {
41 | description: 'OK',
42 | content: {
43 | 'application/json': {
44 | schema: { type: 'object' },
45 | },
46 | },
47 | },
48 | },
49 | },
50 | },
51 | },
52 | }
53 |
54 | const getPetOperation = sampleSpec.paths['/pets/{petId}']?.get as OpenAPIV3.OperationObject & { method: string; path: string }
55 | if (!getPetOperation) {
56 | throw new Error('Test setup error: getPet operation not found in sample spec')
57 | }
58 |
59 | beforeEach(async () => {
60 | // Create a new instance of HttpClient
61 | client = new HttpClient({ baseUrl: 'https://api.example.com' }, sampleSpec)
62 | // Await the initialization to ensure mockApi is set correctly
63 | mockApi = await client['api']
64 | })
65 |
66 | afterEach(() => {
67 | vi.clearAllMocks()
68 | })
69 |
70 | it('successfully executes an operation', async () => {
71 | const mockResponse = {
72 | data: { id: 1, name: 'Fluffy' },
73 | status: 200,
74 | headers: {
75 | 'content-type': 'application/json',
76 | },
77 | }
78 |
79 | mockApi.getPet.mockResolvedValueOnce(mockResponse)
80 |
81 | const response = await client.executeOperation(getPetOperation, { petId: 1 })
82 |
83 | // Note GET requests should have a null Content-Type header!
84 | expect(mockApi.getPet).toHaveBeenCalledWith({ petId: 1 }, undefined, { headers: { 'Content-Type': null } })
85 | expect(response.data).toEqual(mockResponse.data)
86 | expect(response.status).toBe(200)
87 | expect(response.headers).toBeInstanceOf(Headers)
88 | expect(response.headers.get('content-type')).toBe('application/json')
89 | })
90 |
91 | it('throws error when operation ID is missing', async () => {
92 | const operationWithoutId: OpenAPIV3.OperationObject & { method: string; path: string } = {
93 | method: 'GET',
94 | path: '/unknown',
95 | responses: {
96 | '200': {
97 | description: 'OK',
98 | },
99 | },
100 | }
101 |
102 | await expect(client.executeOperation(operationWithoutId)).rejects.toThrow('Operation ID is required')
103 | })
104 |
105 | it('throws error when operation is not found', async () => {
106 | const operation: OpenAPIV3.OperationObject & { method: string; path: string } = {
107 | method: 'GET',
108 | path: '/unknown',
109 | operationId: 'nonexistentOperation',
110 | responses: {
111 | '200': {
112 | description: 'OK',
113 | },
114 | },
115 | }
116 |
117 | await expect(client.executeOperation(operation)).rejects.toThrow('Operation nonexistentOperation not found')
118 | })
119 |
120 | it('handles API errors correctly', async () => {
121 | const error = {
122 | response: {
123 | status: 404,
124 | statusText: 'Not Found',
125 | data: {
126 | code: 'RESOURCE_NOT_FOUND',
127 | message: 'Pet not found',
128 | petId: 999,
129 | },
130 | headers: {
131 | 'content-type': 'application/json',
132 | },
133 | },
134 | }
135 | mockApi.getPet.mockRejectedValueOnce(error)
136 |
137 | await expect(client.executeOperation(getPetOperation, { petId: 999 })).rejects.toMatchObject({
138 | status: 404,
139 | message: '404 Not Found',
140 | data: {
141 | code: 'RESOURCE_NOT_FOUND',
142 | message: 'Pet not found',
143 | petId: 999,
144 | },
145 | })
146 | })
147 |
148 | it('handles validation errors (400) correctly', async () => {
149 | const error = {
150 | response: {
151 | status: 400,
152 | statusText: 'Bad Request',
153 | data: {
154 | code: 'VALIDATION_ERROR',
155 | message: 'Invalid input data',
156 | errors: [
157 | {
158 | field: 'age',
159 | message: 'Age must be a positive number',
160 | },
161 | {
162 | field: 'name',
163 | message: 'Name is required',
164 | },
165 | ],
166 | },
167 | headers: {
168 | 'content-type': 'application/json',
169 | },
170 | },
171 | }
172 | mockApi.getPet.mockRejectedValueOnce(error)
173 |
174 | await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({
175 | status: 400,
176 | message: '400 Bad Request',
177 | data: {
178 | code: 'VALIDATION_ERROR',
179 | message: 'Invalid input data',
180 | errors: [
181 | {
182 | field: 'age',
183 | message: 'Age must be a positive number',
184 | },
185 | {
186 | field: 'name',
187 | message: 'Name is required',
188 | },
189 | ],
190 | },
191 | })
192 | })
193 |
194 | it('handles server errors (500) with HTML response', async () => {
195 | const error = {
196 | response: {
197 | status: 500,
198 | statusText: 'Internal Server Error',
199 | data: '<html><body><h1>500 Internal Server Error</h1></body></html>',
200 | headers: {
201 | 'content-type': 'text/html',
202 | },
203 | },
204 | }
205 | mockApi.getPet.mockRejectedValueOnce(error)
206 |
207 | await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({
208 | status: 500,
209 | message: '500 Internal Server Error',
210 | data: '<html><body><h1>500 Internal Server Error</h1></body></html>',
211 | })
212 | })
213 |
214 | it('handles rate limit errors (429)', async () => {
215 | const error = {
216 | response: {
217 | status: 429,
218 | statusText: 'Too Many Requests',
219 | data: {
220 | code: 'RATE_LIMIT_EXCEEDED',
221 | message: 'Rate limit exceeded',
222 | retryAfter: 60,
223 | },
224 | headers: {
225 | 'content-type': 'application/json',
226 | 'retry-after': '60',
227 | },
228 | },
229 | }
230 | mockApi.getPet.mockRejectedValueOnce(error)
231 |
232 | await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({
233 | status: 429,
234 | message: '429 Too Many Requests',
235 | data: {
236 | code: 'RATE_LIMIT_EXCEEDED',
237 | message: 'Rate limit exceeded',
238 | retryAfter: 60,
239 | },
240 | })
241 | })
242 |
243 | it('should send body parameters in request body for POST operations', async () => {
244 | // Setup mock API with the new operation
245 | mockApi.testOperation = vi.fn().mockResolvedValue({
246 | data: {},
247 | status: 200,
248 | headers: {},
249 | })
250 |
251 | const testSpec: OpenAPIV3.Document = {
252 | openapi: '3.0.0',
253 | info: { title: 'Test API', version: '1.0.0' },
254 | paths: {
255 | '/test': {
256 | post: {
257 | operationId: 'testOperation',
258 | requestBody: {
259 | content: {
260 | 'application/json': {
261 | schema: {
262 | type: 'object',
263 | properties: {
264 | foo: { type: 'string' },
265 | },
266 | },
267 | },
268 | },
269 | },
270 | responses: {
271 | '200': {
272 | description: 'Success response',
273 | content: {
274 | 'application/json': {
275 | schema: {
276 | type: 'object',
277 | },
278 | },
279 | },
280 | },
281 | },
282 | },
283 | },
284 | },
285 | }
286 |
287 | const postOperation = testSpec.paths['/test']?.post as OpenAPIV3.OperationObject & { method: string; path: string }
288 | if (!postOperation) {
289 | throw new Error('Test setup error: post operation not found')
290 | }
291 |
292 | const client = new HttpClient({ baseUrl: 'http://test.com' }, testSpec)
293 |
294 | await client.executeOperation(postOperation, { foo: 'bar' })
295 |
296 | expect(mockApi.testOperation).toHaveBeenCalledWith({}, { foo: 'bar' }, { headers: { 'Content-Type': 'application/json' } })
297 | })
298 |
299 | it('should handle query, path, and body parameters correctly', async () => {
300 | mockApi.complexOperation = vi.fn().mockResolvedValue({
301 | data: { success: true },
302 | status: 200,
303 | headers: {
304 | 'content-type': 'application/json',
305 | },
306 | })
307 |
308 | const complexSpec: OpenAPIV3.Document = {
309 | openapi: '3.0.0',
310 | info: { title: 'Test API', version: '1.0.0' },
311 | paths: {
312 | '/users/{userId}/posts': {
313 | post: {
314 | operationId: 'complexOperation',
315 | parameters: [
316 | {
317 | name: 'userId',
318 | in: 'path',
319 | required: true,
320 | schema: { type: 'integer' },
321 | },
322 | {
323 | name: 'include',
324 | in: 'query',
325 | required: false,
326 | schema: { type: 'string' },
327 | },
328 | ],
329 | requestBody: {
330 | content: {
331 | 'application/json': {
332 | schema: {
333 | type: 'object',
334 | properties: {
335 | title: { type: 'string' },
336 | content: { type: 'string' },
337 | },
338 | },
339 | },
340 | },
341 | },
342 | responses: {
343 | '200': {
344 | description: 'Success response',
345 | content: {
346 | 'application/json': {
347 | schema: {
348 | type: 'object',
349 | properties: {
350 | success: { type: 'boolean' },
351 | },
352 | },
353 | },
354 | },
355 | },
356 | },
357 | },
358 | },
359 | },
360 | }
361 |
362 | const complexOperation = complexSpec.paths['/users/{userId}/posts']?.post as OpenAPIV3.OperationObject & {
363 | method: string
364 | path: string
365 | }
366 | if (!complexOperation) {
367 | throw new Error('Test setup error: complex operation not found')
368 | }
369 |
370 | const client = new HttpClient({ baseUrl: 'http://test.com' }, complexSpec)
371 |
372 | await client.executeOperation(complexOperation, {
373 | // Path parameter
374 | userId: 123,
375 | // Query parameter
376 | include: 'comments',
377 | // Body parameters
378 | title: 'Test Post',
379 | content: 'Test Content',
380 | })
381 |
382 | expect(mockApi.complexOperation).toHaveBeenCalledWith(
383 | {
384 | userId: 123,
385 | include: 'comments',
386 | },
387 | {
388 | title: 'Test Post',
389 | content: 'Test Content',
390 | },
391 | { headers: { 'Content-Type': 'application/json' } },
392 | )
393 | })
394 |
395 | const mockOpenApiSpec: OpenAPIV3.Document = {
396 | openapi: '3.0.0',
397 | info: { title: 'Test API', version: '1.0.0' },
398 | paths: {
399 | '/test': {
400 | post: {
401 | operationId: 'testOperation',
402 | parameters: [
403 | {
404 | name: 'queryParam',
405 | in: 'query',
406 | schema: { type: 'string' },
407 | },
408 | {
409 | name: 'pathParam',
410 | in: 'path',
411 | schema: { type: 'string' },
412 | },
413 | ],
414 | requestBody: {
415 | content: {
416 | 'application/json': {
417 | schema: {
418 | type: 'object',
419 | properties: {
420 | bodyParam: { type: 'string' },
421 | },
422 | },
423 | },
424 | },
425 | },
426 | responses: {
427 | '200': {
428 | description: 'Success',
429 | },
430 | '400': {
431 | description: 'Bad Request',
432 | },
433 | },
434 | },
435 | },
436 | },
437 | }
438 |
439 | const mockConfig = {
440 | baseUrl: 'http://test-api.com',
441 | }
442 |
443 | beforeEach(() => {
444 | vi.clearAllMocks()
445 | })
446 |
447 | it('should properly propagate structured error responses', async () => {
448 | const errorResponse = {
449 | response: {
450 | data: {
451 | code: 'VALIDATION_ERROR',
452 | message: 'Invalid input',
453 | details: ['Field x is required'],
454 | },
455 | status: 400,
456 | statusText: 'Bad Request',
457 | headers: {
458 | 'content-type': 'application/json',
459 | },
460 | },
461 | }
462 |
463 | // Mock axios instance
464 | const mockAxiosInstance = {
465 | testOperation: vi.fn().mockRejectedValue(errorResponse),
466 | }
467 |
468 | // Mock the OpenAPIClientAxios initialization
469 | const MockOpenAPIClientAxios = vi.fn().mockImplementation(() => ({
470 | init: () => Promise.resolve(mockAxiosInstance),
471 | }))
472 |
473 | vi.mocked(OpenAPIClientAxios).mockImplementation(() => MockOpenAPIClientAxios())
474 |
475 | const client = new HttpClient(mockConfig, mockOpenApiSpec)
476 | const operation = mockOpenApiSpec.paths['/test']?.post
477 | if (!operation) {
478 | throw new Error('Operation not found in mock spec')
479 | }
480 |
481 | try {
482 | await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, {})
483 | // Should not reach here
484 | expect(true).toBe(false)
485 | } catch (error: any) {
486 | expect(error.status).toBe(400)
487 | expect(error.data).toEqual({
488 | code: 'VALIDATION_ERROR',
489 | message: 'Invalid input',
490 | details: ['Field x is required'],
491 | })
492 | expect(error.message).toBe('400 Bad Request')
493 | }
494 | })
495 |
496 | it('should handle query, path, and body parameters correctly', async () => {
497 | const mockAxiosInstance = {
498 | testOperation: vi.fn().mockResolvedValue({
499 | data: { success: true },
500 | status: 200,
501 | headers: { 'content-type': 'application/json' },
502 | }),
503 | }
504 |
505 | const MockOpenAPIClientAxios = vi.fn().mockImplementation(() => ({
506 | init: () => Promise.resolve(mockAxiosInstance),
507 | }))
508 |
509 | vi.mocked(OpenAPIClientAxios).mockImplementation(() => MockOpenAPIClientAxios())
510 |
511 | const client = new HttpClient(mockConfig, mockOpenApiSpec)
512 | const operation = mockOpenApiSpec.paths['/test']?.post
513 | if (!operation) {
514 | throw new Error('Operation not found in mock spec')
515 | }
516 |
517 | const response = await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, {
518 | queryParam: 'query1',
519 | pathParam: 'path1',
520 | bodyParam: 'body1',
521 | })
522 |
523 | expect(mockAxiosInstance.testOperation).toHaveBeenCalledWith(
524 | {
525 | queryParam: 'query1',
526 | pathParam: 'path1',
527 | },
528 | {
529 | bodyParam: 'body1',
530 | },
531 | { headers: { 'Content-Type': 'application/json' } },
532 | )
533 |
534 | // Additional check to ensure headers are correctly processed
535 | expect(response.headers.get('content-type')).toBe('application/json')
536 | })
537 | })
538 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/parser.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
2 | import type { JSONSchema7 as IJsonSchema } from 'json-schema'
3 | import type { ChatCompletionTool } from 'openai/resources/chat/completions'
4 | import type { Tool } from '@anthropic-ai/sdk/resources/messages/messages'
5 |
6 | type NewToolMethod = {
7 | name: string
8 | description: string
9 | inputSchema: IJsonSchema & { type: 'object' }
10 | returnSchema?: IJsonSchema
11 | }
12 |
13 | type FunctionParameters = {
14 | type: 'object'
15 | properties?: Record<string, unknown>
16 | required?: string[]
17 | [key: string]: unknown
18 | }
19 |
20 | export class OpenAPIToMCPConverter {
21 | private schemaCache: Record<string, IJsonSchema> = {}
22 | private nameCounter: number = 0
23 |
24 | constructor(private openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {}
25 |
26 | /**
27 | * Resolve a $ref reference to its schema in the openApiSpec.
28 | * Returns the raw OpenAPI SchemaObject or null if not found.
29 | */
30 | private internalResolveRef(ref: string, resolvedRefs: Set<string>): OpenAPIV3.SchemaObject | null {
31 | if (!ref.startsWith('#/')) {
32 | return null
33 | }
34 | if (resolvedRefs.has(ref)) {
35 | return null
36 | }
37 |
38 | const parts = ref.replace(/^#\//, '').split('/')
39 | let current: any = this.openApiSpec
40 | for (const part of parts) {
41 | current = current[part]
42 | if (!current) return null
43 | }
44 | resolvedRefs.add(ref)
45 | return current as OpenAPIV3.SchemaObject
46 | }
47 |
48 | /**
49 | * Convert an OpenAPI schema (or reference) into a JSON Schema object.
50 | * Uses caching and handles cycles by returning $ref nodes.
51 | */
52 | convertOpenApiSchemaToJsonSchema(
53 | schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
54 | resolvedRefs: Set<string>,
55 | resolveRefs: boolean = false,
56 | ): IJsonSchema {
57 | if ('$ref' in schema) {
58 | const ref = schema.$ref
59 | if (!resolveRefs) {
60 | if (ref.startsWith('#/components/schemas/')) {
61 | return {
62 | $ref: ref.replace(/^#\/components\/schemas\//, '#/$defs/'),
63 | ...('description' in schema ? { description: schema.description as string } : {}),
64 | }
65 | }
66 | console.error(`Attempting to resolve ref ${ref} not found in components collection.`)
67 | // deliberate fall through
68 | }
69 | // Create base schema with $ref and description if present
70 | const refSchema: IJsonSchema = { $ref: ref }
71 | if ('description' in schema && schema.description) {
72 | refSchema.description = schema.description as string
73 | }
74 |
75 | // If already cached, return immediately with description
76 | if (this.schemaCache[ref]) {
77 | return this.schemaCache[ref]
78 | }
79 |
80 | const resolved = this.internalResolveRef(ref, resolvedRefs)
81 | if (!resolved) {
82 | // TODO: need extensive tests for this and we definitely need to handle the case of self references
83 | console.error(`Failed to resolve ref ${ref}`)
84 | return {
85 | $ref: ref.replace(/^#\/components\/schemas\//, '#/$defs/'),
86 | description: 'description' in schema ? ((schema.description as string) ?? '') : '',
87 | }
88 | } else {
89 | const converted = this.convertOpenApiSchemaToJsonSchema(resolved, resolvedRefs, resolveRefs)
90 | this.schemaCache[ref] = converted
91 |
92 | return converted
93 | }
94 | }
95 |
96 | // Handle inline schema
97 | const result: IJsonSchema = {}
98 |
99 | if (schema.type) {
100 | result.type = schema.type as IJsonSchema['type']
101 | }
102 |
103 | // Convert binary format to uri-reference and enhance description
104 | if (schema.format === 'binary') {
105 | result.format = 'uri-reference'
106 | const binaryDesc = 'absolute paths to local files'
107 | result.description = schema.description ? `${schema.description} (${binaryDesc})` : binaryDesc
108 | } else {
109 | if (schema.format) {
110 | result.format = schema.format
111 | }
112 | if (schema.description) {
113 | result.description = schema.description
114 | }
115 | }
116 |
117 | if (schema.enum) {
118 | result.enum = schema.enum
119 | }
120 |
121 | if (schema.default !== undefined) {
122 | result.default = schema.default
123 | }
124 |
125 | // Handle object properties
126 | if (schema.type === 'object') {
127 | result.type = 'object'
128 | if (schema.properties) {
129 | result.properties = {}
130 | for (const [name, propSchema] of Object.entries(schema.properties)) {
131 | result.properties[name] = this.convertOpenApiSchemaToJsonSchema(propSchema, resolvedRefs, resolveRefs)
132 | }
133 | }
134 | if (schema.required) {
135 | result.required = schema.required
136 | }
137 | if (schema.additionalProperties === true || schema.additionalProperties === undefined) {
138 | result.additionalProperties = true
139 | } else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
140 | result.additionalProperties = this.convertOpenApiSchemaToJsonSchema(schema.additionalProperties, resolvedRefs, resolveRefs)
141 | } else {
142 | result.additionalProperties = false
143 | }
144 | }
145 |
146 | // Handle arrays - ensure binary format conversion happens for array items too
147 | if (schema.type === 'array' && schema.items) {
148 | result.type = 'array'
149 | result.items = this.convertOpenApiSchemaToJsonSchema(schema.items, resolvedRefs, resolveRefs)
150 | }
151 |
152 | // oneOf, anyOf, allOf
153 | if (schema.oneOf) {
154 | result.oneOf = schema.oneOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs))
155 | }
156 | if (schema.anyOf) {
157 | result.anyOf = schema.anyOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs))
158 | }
159 | if (schema.allOf) {
160 | result.allOf = schema.allOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs))
161 | }
162 |
163 | return result
164 | }
165 |
166 | convertToMCPTools(): {
167 | tools: Record<string, { methods: NewToolMethod[] }>
168 | openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
169 | zip: Record<string, { openApi: OpenAPIV3.OperationObject & { method: string; path: string }; mcp: NewToolMethod }>
170 | } {
171 | const apiName = 'API'
172 |
173 | const openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }> = {}
174 | const tools: Record<string, { methods: NewToolMethod[] }> = {
175 | [apiName]: { methods: [] },
176 | }
177 | const zip: Record<string, { openApi: OpenAPIV3.OperationObject & { method: string; path: string }; mcp: NewToolMethod }> = {}
178 | for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
179 | if (!pathItem) continue
180 |
181 | for (const [method, operation] of Object.entries(pathItem)) {
182 | if (!this.isOperation(method, operation)) continue
183 |
184 | const mcpMethod = this.convertOperationToMCPMethod(operation, method, path)
185 | if (mcpMethod) {
186 | const uniqueName = this.ensureUniqueName(mcpMethod.name)
187 | mcpMethod.name = uniqueName
188 | tools[apiName]!.methods.push(mcpMethod)
189 | openApiLookup[apiName + '-' + uniqueName] = { ...operation, method, path }
190 | zip[apiName + '-' + uniqueName] = { openApi: { ...operation, method, path }, mcp: mcpMethod }
191 | }
192 | }
193 | }
194 |
195 | return { tools, openApiLookup, zip }
196 | }
197 |
198 | /**
199 | * Convert the OpenAPI spec to OpenAI's ChatCompletionTool format
200 | */
201 | convertToOpenAITools(): ChatCompletionTool[] {
202 | const tools: ChatCompletionTool[] = []
203 |
204 | for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
205 | if (!pathItem) continue
206 |
207 | for (const [method, operation] of Object.entries(pathItem)) {
208 | if (!this.isOperation(method, operation)) continue
209 |
210 | const parameters = this.convertOperationToJsonSchema(operation, method, path)
211 | const tool: ChatCompletionTool = {
212 | type: 'function',
213 | function: {
214 | name: operation.operationId!,
215 | description: operation.summary || operation.description || '',
216 | parameters: parameters as FunctionParameters,
217 | },
218 | }
219 | tools.push(tool)
220 | }
221 | }
222 |
223 | return tools
224 | }
225 |
226 | /**
227 | * Convert the OpenAPI spec to Anthropic's Tool format
228 | */
229 | convertToAnthropicTools(): Tool[] {
230 | const tools: Tool[] = []
231 |
232 | for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
233 | if (!pathItem) continue
234 |
235 | for (const [method, operation] of Object.entries(pathItem)) {
236 | if (!this.isOperation(method, operation)) continue
237 |
238 | const parameters = this.convertOperationToJsonSchema(operation, method, path)
239 | const tool: Tool = {
240 | name: operation.operationId!,
241 | description: operation.summary || operation.description || '',
242 | input_schema: parameters as Tool['input_schema'],
243 | }
244 | tools.push(tool)
245 | }
246 | }
247 |
248 | return tools
249 | }
250 |
251 | private convertComponentsToJsonSchema(): Record<string, IJsonSchema> {
252 | const components = this.openApiSpec.components || {}
253 | const schema: Record<string, IJsonSchema> = {}
254 | for (const [key, value] of Object.entries(components.schemas || {})) {
255 | schema[key] = this.convertOpenApiSchemaToJsonSchema(value, new Set())
256 | }
257 | return schema
258 | }
259 | /**
260 | * Helper method to convert an operation to a JSON Schema for parameters
261 | */
262 | private convertOperationToJsonSchema(
263 | operation: OpenAPIV3.OperationObject,
264 | method: string,
265 | path: string,
266 | ): IJsonSchema & { type: 'object' } {
267 | const schema: IJsonSchema & { type: 'object' } = {
268 | type: 'object',
269 | properties: {},
270 | required: [],
271 | $defs: this.convertComponentsToJsonSchema(),
272 | }
273 |
274 | // Handle parameters (path, query, header, cookie)
275 | if (operation.parameters) {
276 | for (const param of operation.parameters) {
277 | const paramObj = this.resolveParameter(param)
278 | if (paramObj && paramObj.schema) {
279 | const paramSchema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set())
280 | // Merge parameter-level description if available
281 | if (paramObj.description) {
282 | paramSchema.description = paramObj.description
283 | }
284 | schema.properties![paramObj.name] = paramSchema
285 | if (paramObj.required) {
286 | schema.required!.push(paramObj.name)
287 | }
288 | }
289 | }
290 | }
291 |
292 | // Handle requestBody
293 | if (operation.requestBody) {
294 | const bodyObj = this.resolveRequestBody(operation.requestBody)
295 | if (bodyObj?.content) {
296 | if (bodyObj.content['application/json']?.schema) {
297 | const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set())
298 | if (bodySchema.type === 'object' && bodySchema.properties) {
299 | for (const [name, propSchema] of Object.entries(bodySchema.properties)) {
300 | schema.properties![name] = propSchema
301 | }
302 | if (bodySchema.required) {
303 | schema.required!.push(...bodySchema.required)
304 | }
305 | }
306 | }
307 | }
308 | }
309 |
310 | return schema
311 | }
312 |
313 | private isOperation(method: string, operation: any): operation is OpenAPIV3.OperationObject {
314 | return ['get', 'post', 'put', 'delete', 'patch'].includes(method.toLowerCase())
315 | }
316 |
317 | private isParameterObject(param: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject): param is OpenAPIV3.ParameterObject {
318 | return !('$ref' in param)
319 | }
320 |
321 | private isRequestBodyObject(body: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject): body is OpenAPIV3.RequestBodyObject {
322 | return !('$ref' in body)
323 | }
324 |
325 | private resolveParameter(param: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject): OpenAPIV3.ParameterObject | null {
326 | if (this.isParameterObject(param)) {
327 | return param
328 | } else {
329 | const resolved = this.internalResolveRef(param.$ref, new Set())
330 | if (resolved && (resolved as OpenAPIV3.ParameterObject).name) {
331 | return resolved as OpenAPIV3.ParameterObject
332 | }
333 | }
334 | return null
335 | }
336 |
337 | private resolveRequestBody(body: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject): OpenAPIV3.RequestBodyObject | null {
338 | if (this.isRequestBodyObject(body)) {
339 | return body
340 | } else {
341 | const resolved = this.internalResolveRef(body.$ref, new Set())
342 | if (resolved) {
343 | return resolved as OpenAPIV3.RequestBodyObject
344 | }
345 | }
346 | return null
347 | }
348 |
349 | private resolveResponse(response: OpenAPIV3.ResponseObject | OpenAPIV3.ReferenceObject): OpenAPIV3.ResponseObject | null {
350 | if ('$ref' in response) {
351 | const resolved = this.internalResolveRef(response.$ref, new Set())
352 | if (resolved) {
353 | return resolved as OpenAPIV3.ResponseObject
354 | } else {
355 | return null
356 | }
357 | }
358 | return response
359 | }
360 |
361 | private convertOperationToMCPMethod(operation: OpenAPIV3.OperationObject, method: string, path: string): NewToolMethod | null {
362 | if (!operation.operationId) {
363 | console.warn(`Operation without operationId at ${method} ${path}`)
364 | return null
365 | }
366 |
367 | const methodName = operation.operationId
368 |
369 | const inputSchema: IJsonSchema & { type: 'object' } = {
370 | $defs: this.convertComponentsToJsonSchema(),
371 | type: 'object',
372 | properties: {},
373 | required: [],
374 | }
375 |
376 | // Handle parameters (path, query, header, cookie)
377 | if (operation.parameters) {
378 | for (const param of operation.parameters) {
379 | const paramObj = this.resolveParameter(param)
380 | if (paramObj && paramObj.schema) {
381 | const schema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set(), false)
382 | // Merge parameter-level description if available
383 | if (paramObj.description) {
384 | schema.description = paramObj.description
385 | }
386 | inputSchema.properties![paramObj.name] = schema
387 | if (paramObj.required) {
388 | inputSchema.required!.push(paramObj.name)
389 | }
390 | }
391 | }
392 | }
393 |
394 | // Handle requestBody
395 | if (operation.requestBody) {
396 | const bodyObj = this.resolveRequestBody(operation.requestBody)
397 | if (bodyObj?.content) {
398 | // Handle multipart/form-data for file uploads
399 | // We convert the multipart/form-data schema to a JSON schema and we require
400 | // that the user passes in a string for each file that points to the local file
401 | if (bodyObj.content['multipart/form-data']?.schema) {
402 | const formSchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['multipart/form-data'].schema, new Set(), false)
403 | if (formSchema.type === 'object' && formSchema.properties) {
404 | for (const [name, propSchema] of Object.entries(formSchema.properties)) {
405 | inputSchema.properties![name] = propSchema
406 | }
407 | if (formSchema.required) {
408 | inputSchema.required!.push(...formSchema.required!)
409 | }
410 | }
411 | }
412 | // Handle application/json
413 | else if (bodyObj.content['application/json']?.schema) {
414 | const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set(), false)
415 | // Merge body schema into the inputSchema's properties
416 | if (bodySchema.type === 'object' && bodySchema.properties) {
417 | for (const [name, propSchema] of Object.entries(bodySchema.properties)) {
418 | inputSchema.properties![name] = propSchema
419 | }
420 | if (bodySchema.required) {
421 | inputSchema.required!.push(...bodySchema.required!)
422 | }
423 | } else {
424 | // If the request body is not an object, just put it under "body"
425 | inputSchema.properties!['body'] = bodySchema
426 | inputSchema.required!.push('body')
427 | }
428 | }
429 | }
430 | }
431 |
432 | // Build description including error responses
433 | let description = operation.summary || operation.description || ''
434 | if (operation.responses) {
435 | const errorResponses = Object.entries(operation.responses)
436 | .filter(([code]) => code.startsWith('4') || code.startsWith('5'))
437 | .map(([code, response]) => {
438 | const responseObj = this.resolveResponse(response)
439 | let errorDesc = responseObj?.description || ''
440 | return `${code}: ${errorDesc}`
441 | })
442 |
443 | if (errorResponses.length > 0) {
444 | description += '\nError Responses:\n' + errorResponses.join('\n')
445 | }
446 | }
447 |
448 | // Extract return type (response schema)
449 | const returnSchema = this.extractResponseType(operation.responses)
450 |
451 | // Generate Zod schema from input schema
452 | try {
453 | // const zodSchemaStr = jsonSchemaToZod(inputSchema, { module: "cjs" })
454 | // console.log(zodSchemaStr)
455 | // // Execute the function with the zod instance
456 | // const zodSchema = eval(zodSchemaStr) as z.ZodType
457 |
458 | return {
459 | name: methodName,
460 | description,
461 | inputSchema,
462 | ...(returnSchema ? { returnSchema } : {}),
463 | }
464 | } catch (error) {
465 | console.warn(`Failed to generate Zod schema for ${methodName}:`, error)
466 | // Fallback to a basic object schema
467 | return {
468 | name: methodName,
469 | description,
470 | inputSchema,
471 | ...(returnSchema ? { returnSchema } : {}),
472 | }
473 | }
474 | }
475 |
476 | private extractResponseType(responses: OpenAPIV3.ResponsesObject | undefined): IJsonSchema | null {
477 | // Look for a success response
478 | const successResponse = responses?.['200'] || responses?.['201'] || responses?.['202'] || responses?.['204']
479 | if (!successResponse) return null
480 |
481 | const responseObj = this.resolveResponse(successResponse)
482 | if (!responseObj || !responseObj.content) return null
483 |
484 | if (responseObj.content['application/json']?.schema) {
485 | const returnSchema = this.convertOpenApiSchemaToJsonSchema(responseObj.content['application/json'].schema, new Set(), false)
486 | returnSchema['$defs'] = this.convertComponentsToJsonSchema()
487 |
488 | // Preserve the response description if available and not already set
489 | if (responseObj.description && !returnSchema.description) {
490 | returnSchema.description = responseObj.description
491 | }
492 |
493 | return returnSchema
494 | }
495 |
496 | // If no JSON response, fallback to a generic string or known formats
497 | if (responseObj.content['image/png'] || responseObj.content['image/jpeg']) {
498 | return { type: 'string', format: 'binary', description: responseObj.description || '' }
499 | }
500 |
501 | // Fallback
502 | return { type: 'string', description: responseObj.description || '' }
503 | }
504 |
505 | private ensureUniqueName(name: string): string {
506 | if (name.length <= 64) {
507 | return name
508 | }
509 |
510 | const truncatedName = name.slice(0, 64 - 5) // Reserve space for suffix
511 | const uniqueSuffix = this.generateUniqueSuffix()
512 | return `${truncatedName}-${uniqueSuffix}`
513 | }
514 |
515 | private generateUniqueSuffix(): string {
516 | this.nameCounter += 1
517 | return this.nameCounter.toString().padStart(4, '0')
518 | }
519 | }
520 |
```
--------------------------------------------------------------------------------
/scripts/notion-openapi.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "openapi": "3.1.0",
3 | "info": {
4 | "title": "Notion API",
5 | "version": "1"
6 | },
7 | "servers": [
8 | {
9 | "url": "https://api.notion.com"
10 | }
11 | ],
12 | "components": {
13 | "securitySchemes": {
14 | "bearerAuth": {
15 | "type": "http",
16 | "scheme": "bearer"
17 | },
18 | "basicAuth": {
19 | "type": "http",
20 | "scheme": "basic"
21 | }
22 | },
23 | "parameters": {},
24 | "schemas": {}
25 | },
26 | "security": [
27 | {
28 | "bearerAuth": []
29 | }
30 | ],
31 | "paths": {
32 | "/v1/blocks/{block_id}/children": {
33 | "get": {
34 | "summary": "Retrieve block children",
35 | "description": "",
36 | "operationId": "get-block-children",
37 | "parameters": [
38 | {
39 | "name": "block_id",
40 | "in": "path",
41 | "description": "Identifier for a [block](ref:block)",
42 | "schema": {
43 | "type": "string"
44 | },
45 | "required": true
46 | },
47 | {
48 | "name": "start_cursor",
49 | "in": "query",
50 | "description": "If supplied, this endpoint will return a page of results starting after the cursor provided. If not supplied, this endpoint will return the first page of results.",
51 | "schema": {
52 | "type": "string"
53 | }
54 | },
55 | {
56 | "name": "page_size",
57 | "in": "query",
58 | "description": "The number of items from the full list desired in the response. Maximum: 100",
59 | "schema": {
60 | "type": "integer",
61 | "format": "int32",
62 | "default": 100
63 | }
64 | }
65 | ],
66 | "responses": {},
67 | "deprecated": false,
68 | "security": []
69 | }
70 | },
71 | "/v1/pages/{page_id}": {
72 | "get": {
73 | "summary": "Retrieve a page",
74 | "description": "",
75 | "operationId": "retrieve-a-page",
76 | "parameters": [
77 | {
78 | "name": "page_id",
79 | "in": "path",
80 | "description": "Identifier for a Notion page",
81 | "schema": {
82 | "type": "string"
83 | },
84 | "required": true
85 | },
86 | {
87 | "name": "filter_properties",
88 | "in": "query",
89 | "description": "A list of page property value IDs associated with the page. Use this param to limit the response to a specific page property value or values. To retrieve multiple properties, specify each page property ID. For example: `?filter_properties=iAk8&filter_properties=b7dh`.",
90 | "schema": {
91 | "type": "string"
92 | }
93 | }
94 | ],
95 | "responses": {},
96 | "deprecated": false,
97 | "security": []
98 | }
99 | },
100 | "/v1/blocks/{block_id}": {
101 | "get": {
102 | "summary": "Retrieve a block",
103 | "description": "",
104 | "operationId": "retrieve-a-block",
105 | "parameters": [
106 | {
107 | "name": "block_id",
108 | "in": "path",
109 | "description": "Identifier for a Notion block",
110 | "schema": {
111 | "type": "string"
112 | },
113 | "required": true
114 | }
115 | ],
116 | "responses": {},
117 | "deprecated": false,
118 | "security": []
119 | }
120 | },
121 | "/v1/databases/{database_id}": {
122 | "get": {
123 | "summary": "Retrieve a database",
124 | "description": "",
125 | "operationId": "retrieve-a-database",
126 | "parameters": [
127 | {
128 | "name": "database_id",
129 | "in": "path",
130 | "description": "An identifier for the Notion database.",
131 | "schema": {
132 | "type": "string"
133 | },
134 | "required": true
135 | }
136 | ],
137 | "responses": {
138 | "200": {
139 | "description": "200",
140 | "content": {
141 | "application/json": {
142 | "examples": {
143 | "Result": {
144 | "value": "{\n \"object\": \"database\",\n \"id\": \"bc1211ca-e3f1-4939-ae34-5260b16f627c\",\n \"created_time\": \"2021-07-08T23:50:00.000Z\",\n \"last_edited_time\": \"2021-07-08T23:50:00.000Z\",\n \"icon\": {\n \"type\": \"emoji\",\n \"emoji\": \"🎉\"\n },\n \"cover\": {\n \"type\": \"external\",\n \"external\": {\n \"url\": \"https://website.domain/images/image.png\"\n }\n },\n \"url\": \"https://www.notion.so/bc1211cae3f14939ae34260b16f627c\",\n \"title\": [\n {\n \"type\": \"text\",\n \"text\": {\n \"content\": \"Grocery List\",\n \"link\": null\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \"Grocery List\",\n \"href\": null\n }\n ],\n \"description\": [\n {\n \"type\": \"text\",\n \"text\": {\n \"content\": \"Grocery list for just kale 🥬\",\n \"link\": null\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \"Grocery list for just kale 🥬\",\n \"href\": null\n }\n ],\n \"properties\": {\n \"+1\": {\n \"id\": \"Wp%3DC\",\n \"name\": \"+1\",\n \"type\": \"people\",\n \"people\": {}\n },\n \"In stock\": {\n \"id\": \"fk%5EY\",\n \"name\": \"In stock\",\n \"type\": \"checkbox\",\n \"checkbox\": {}\n },\n \"Price\": {\n \"id\": \"evWq\",\n \"name\": \"Price\",\n \"type\": \"number\",\n \"number\": {\n \"format\": \"dollar\"\n }\n },\n \"Description\": {\n \"id\": \"V}lX\",\n \"name\": \"Description\",\n \"type\": \"rich_text\",\n \"rich_text\": {}\n },\n \"Last ordered\": {\n \"id\": \"eVnV\",\n \"name\": \"Last ordered\",\n \"type\": \"date\",\n \"date\": {}\n },\n \"Meals\": {\n \"id\": \"%7DWA~\",\n \"name\": \"Meals\",\n \"type\": \"relation\",\n \"relation\": {\n \"database_id\": \"668d797c-76fa-4934-9b05-ad288df2d136\",\n \"synced_property_name\": \"Related to Grocery List (Meals)\"\n }\n },\n \"Number of meals\": {\n \"id\": \"Z\\\\Eh\",\n \"name\": \"Number of meals\",\n \"type\": \"rollup\",\n \"rollup\": {\n \"rollup_property_name\": \"Name\",\n \"relation_property_name\": \"Meals\",\n \"rollup_property_id\": \"title\",\n \"relation_property_id\": \"mxp^\",\n \"function\": \"count\"\n }\n },\n \"Store availability\": {\n \"id\": \"s}Kq\",\n \"name\": \"Store availability\",\n \"type\": \"multi_select\",\n \"multi_select\": {\n \"options\": [\n {\n \"id\": \"cb79b393-d1c1-4528-b517-c450859de766\",\n \"name\": \"Duc Loi Market\",\n \"color\": \"blue\"\n },\n {\n \"id\": \"58aae162-75d4-403b-a793-3bc7308e4cd2\",\n \"name\": \"Rainbow Grocery\",\n \"color\": \"gray\"\n },\n {\n \"id\": \"22d0f199-babc-44ff-bd80-a9eae3e3fcbf\",\n \"name\": \"Nijiya Market\",\n \"color\": \"purple\"\n },\n {\n \"id\": \"0d069987-ffb0-4347-bde2-8e4068003dbc\",\n \"name\": \"Gus's Community Market\",\n \"color\": \"yellow\"\n }\n ]\n }\n },\n \"Photo\": {\n \"id\": \"yfiK\",\n \"name\": \"Photo\",\n \"type\": \"files\",\n \"files\": {}\n },\n \"Food group\": {\n \"id\": \"CM%3EH\",\n \"name\": \"Food group\",\n \"type\": \"select\",\n \"select\": {\n \"options\": [\n {\n \"id\": \"6d4523fa-88cb-4ffd-9364-1e39d0f4e566\",\n \"name\": \"🥦Vegetable\",\n \"color\": \"green\"\n },\n {\n \"id\": \"268d7e75-de8f-4c4b-8b9d-de0f97021833\",\n \"name\": \"🍎Fruit\",\n \"color\": \"red\"\n },\n {\n \"id\": \"1b234a00-dc97-489c-b987-829264cfdfef\",\n \"name\": \"💪Protein\",\n \"color\": \"yellow\"\n }\n ]\n }\n },\n \"Name\": {\n \"id\": \"title\",\n \"name\": \"Name\",\n \"type\": \"title\",\n \"title\": {}\n }\n },\n \"parent\": {\n \"type\": \"page_id\",\n \"page_id\": \"98ad959b-2b6a-4774-80ee-00246fb0ea9b\"\n },\n \"archived\": false,\n \"is_inline\": false,\n \"public_url\": null\n}"
145 | }
146 | }
147 | }
148 | }
149 | }
150 | },
151 | "deprecated": false,
152 | "security": []
153 | }
154 | },
155 | "/v1/comments": {
156 | "get": {
157 | "summary": "Retrieve comments",
158 | "description": "Retrieves a list of un-resolved [Comment objects](ref:comment-object) from a page or block.",
159 | "operationId": "retrieve-a-comment",
160 | "parameters": [
161 | {
162 | "name": "block_id",
163 | "in": "query",
164 | "description": "Identifier for a Notion block or page",
165 | "required": true,
166 | "schema": {
167 | "type": "string"
168 | }
169 | },
170 | {
171 | "name": "start_cursor",
172 | "in": "query",
173 | "description": "If supplied, this endpoint will return a page of results starting after the cursor provided. If not supplied, this endpoint will return the first page of results.",
174 | "schema": {
175 | "type": "string"
176 | }
177 | },
178 | {
179 | "name": "page_size",
180 | "in": "query",
181 | "description": "The number of items from the full list desired in the response. Maximum: 100",
182 | "schema": {
183 | "type": "integer",
184 | "format": "int32"
185 | }
186 | }
187 | ],
188 | "responses": {
189 | "200": {
190 | "description": "200",
191 | "content": {
192 | "application/json": {
193 | "examples": {
194 | "OK": {
195 | "value": "{\n \"object\": \"list\",\n \"results\": [\n {\n \"object\": \"comment\",\n \"id\": \"94cc56ab-9f02-409d-9f99-1037e9fe502f\",\n \"parent\": {\n \"type\": \"page_id\",\n \"page_id\": \"5c6a2821-6bb1-4a7e-b6e1-c50111515c3d\"\n },\n \"discussion_id\": \"f1407351-36f5-4c49-a13c-49f8ba11776d\",\n \"created_time\": \"2022-07-15T16:52:00.000Z\",\n \"last_edited_time\": \"2022-07-15T19:16:00.000Z\",\n \"created_by\": {\n \"object\": \"user\",\n \"id\": \"9b15170a-9941-4297-8ee6-83fa7649a87a\"\n },\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"text\": {\n \"content\": \"Single comment\",\n \"link\": null\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \"Single comment\",\n \"href\": null\n }\n ]\n }\n ],\n \"next_cursor\": null,\n \"has_more\": false,\n \"type\": \"comment\",\n \"comment\": {}\n}"
196 | }
197 | }
198 | }
199 | }
200 | }
201 | },
202 | "deprecated": false,
203 | "security": []
204 | }
205 | },
206 | "/v1/pages/{page_id}/properties/{property_id}": {
207 | "get": {
208 | "summary": "Retrieve a page property item",
209 | "description": "",
210 | "operationId": "retrieve-a-page-property",
211 | "parameters": [
212 | {
213 | "name": "page_id",
214 | "in": "path",
215 | "description": "Identifier for a Notion page",
216 | "schema": {
217 | "type": "string"
218 | },
219 | "required": true
220 | },
221 | {
222 | "name": "property_id",
223 | "in": "path",
224 | "description": "Identifier for a page [property](https://developers.notion.com/reference/page#all-property-values)",
225 | "schema": {
226 | "type": "string"
227 | },
228 | "required": true
229 | },
230 | {
231 | "name": "page_size",
232 | "in": "query",
233 | "description": "For paginated properties. The max number of property item objects on a page. The default size is 100",
234 | "schema": {
235 | "type": "integer",
236 | "format": "int32"
237 | }
238 | },
239 | {
240 | "name": "start_cursor",
241 | "in": "query",
242 | "description": "For paginated properties.",
243 | "schema": {
244 | "type": "string"
245 | }
246 | }
247 | ],
248 | "responses": {
249 | "200": {
250 | "description": "200",
251 | "content": {
252 | "application/json": {
253 | "examples": {
254 | "Number Property Item": {
255 | "value": "{\n \"object\": \"property_item\",\n \"id\" \"kjPO\",\n \"type\": \"number\",\n \"number\": 2\n}"
256 | },
257 | "Result": {
258 | "value": "{\n \"object\": \"list\",\n \"results\": [\n {\n \"object\": \"property_item\",\n \"id\" \"kjPO\",\n \"type\": \"rich_text\",\n \"rich_text\": {\n \"type\": \"text\",\n \"text\": {\n \"content\": \"Avocado \",\n \"link\": null\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \"Avocado \",\n \"href\": null\n }\n },\n {\n \"object\": \"property_item\",\n \"id\" \"ijPO\",\n \"type\": \"rich_text\",\n \"rich_text\": {\n \"type\": \"mention\",\n \"mention\": {\n \"type\": \"page\",\n \"page\": {\n \"id\": \"41117fd7-69a5-4694-bc07-c1e3a682c857\"\n }\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \"Lemons\",\n \"href\": \"http://notion.so/41117fd769a54694bc07c1e3a682c857\"\n }\n },\n {\n \"object\": \"property_item\",\n \"id\" \"kjPO\",\n \"type\": \"rich_text\",\n \"rich_text\": {\n \"type\": \"text\",\n \"text\": {\n \"content\": \" Tomato \",\n \"link\": null\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \" Tomato \",\n \"href\": null\n }\n },\n...\n ],\n \"next_cursor\": \"some-next-cursor-value\",\n \"has_more\": true,\n\t\t\"next_url\": \"http://api.notion.com/v1/pages/0e5235bf86aa4efb93aa772cce7eab71/properties/NVv^?start_cursor=some-next-cursor-value&page_size=25\",\n \"property_item\": {\n \"id\": \"NVv^\",\n \"next_url\": null,\n \"type\": \"rich_text\",\n \"rich_text\": {}\n }\n}"
259 | },
260 | "Rollup List Property Item": {
261 | "value": "{\n \"object\": \"list\",\n \"results\": [\n {\n \"object\": \"property_item\",\n \t\"id\": \"dj2l\",\n \"type\": \"relation\",\n \"relation\": {\n \"id\": \"83f92c9d-523d-466e-8c1f-9bc2c25a99fe\"\n }\n },\n {\n \"object\": \"property_item\",\n \t\"id\": \"dj2l\",\n \"type\": \"relation\",\n \"relation\": {\n \"id\": \"45cfb825-3463-4891-8932-7e6d8c170630\"\n }\n },\n {\n \"object\": \"property_item\",\n \t\"id\": \"dj2l\",\n \"type\": \"relation\",\n \"relation\": {\n \"id\": \"1688be1a-a197-4f2a-9688-e528c4b56d94\"\n }\n }\n ],\n \"next_cursor\": \"some-next-cursor-value\",\n \"has_more\": true,\n\t\t\"property_item\": {\n \"id\": \"y}~p\",\n \"next_url\": \"http://api.notion.com/v1/pages/0e5235bf86aa4efb93aa772cce7eab71/properties/y%7D~p?start_cursor=1QaTunT5&page_size=25\",\n \"type\": \"rollup\",\n \"rollup\": {\n \"function\": \"sum\",\n \"type\": \"incomplete\",\n \"incomplete\": {}\n }\n }\n \"type\": \"property_item\"\n}"
262 | }
263 | }
264 | }
265 | }
266 | }
267 | },
268 | "deprecated": false,
269 | "security": []
270 | }
271 | }
272 | }
273 | }
274 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { OpenAPIV3 } from 'openapi-types'
2 | import { describe, it, expect } from 'vitest'
3 | import { OpenAPIToMCPConverter } from '../parser'
4 |
5 | describe('OpenAPI Multipart Form Parser', () => {
6 | it('converts single file upload endpoint to tool', () => {
7 | const spec: OpenAPIV3.Document = {
8 | openapi: '3.0.0',
9 | info: { title: 'Test API', version: '1.0.0' },
10 | paths: {
11 | '/pets/{id}/photo': {
12 | post: {
13 | operationId: 'uploadPetPhoto',
14 | summary: 'Upload a photo for a pet',
15 | parameters: [
16 | {
17 | name: 'id',
18 | in: 'path',
19 | required: true,
20 | schema: { type: 'integer' },
21 | },
22 | ],
23 | requestBody: {
24 | required: true,
25 | content: {
26 | 'multipart/form-data': {
27 | schema: {
28 | type: 'object',
29 | required: ['photo'],
30 | properties: {
31 | photo: {
32 | type: 'string',
33 | format: 'binary',
34 | description: 'The photo to upload',
35 | },
36 | caption: {
37 | type: 'string',
38 | description: 'Optional caption for the photo',
39 | },
40 | },
41 | },
42 | },
43 | },
44 | },
45 | responses: {
46 | '201': {
47 | description: 'Photo uploaded successfully',
48 | },
49 | },
50 | },
51 | },
52 | },
53 | }
54 |
55 | const converter = new OpenAPIToMCPConverter(spec)
56 | const { tools } = converter.convertToMCPTools()
57 | expect(Object.keys(tools)).toHaveLength(1)
58 |
59 | const [tool] = Object.values(tools)
60 | expect(tool.methods).toHaveLength(1)
61 | const [method] = tool.methods
62 | expect(method.name).toBe('uploadPetPhoto')
63 | expect(method.description).toContain('Upload a photo for a pet')
64 |
65 | // Check parameters
66 | expect(method.inputSchema.properties).toEqual({
67 | id: {
68 | type: 'integer',
69 | },
70 | photo: {
71 | type: 'string',
72 | format: 'uri-reference',
73 | description: expect.stringContaining('The photo to upload (absolute paths to local files)'),
74 | },
75 | caption: {
76 | type: 'string',
77 | description: expect.stringContaining('Optional caption'),
78 | },
79 | })
80 |
81 | expect(method.inputSchema.required).toContain('id')
82 | expect(method.inputSchema.required).toContain('photo')
83 | expect(method.inputSchema.required).not.toContain('caption')
84 | })
85 |
86 | it('converts multiple file upload endpoint to tool', () => {
87 | const spec: OpenAPIV3.Document = {
88 | openapi: '3.0.0',
89 | info: { title: 'Test API', version: '1.0.0' },
90 | paths: {
91 | '/pets/{id}/documents': {
92 | post: {
93 | operationId: 'uploadPetDocuments',
94 | summary: 'Upload multiple documents for a pet',
95 | parameters: [
96 | {
97 | name: 'id',
98 | in: 'path',
99 | required: true,
100 | schema: { type: 'integer' },
101 | },
102 | ],
103 | requestBody: {
104 | required: true,
105 | content: {
106 | 'multipart/form-data': {
107 | schema: {
108 | type: 'object',
109 | required: ['documents'],
110 | properties: {
111 | documents: {
112 | type: 'array',
113 | items: {
114 | type: 'string',
115 | format: 'binary',
116 | },
117 | description: 'The documents to upload (max 5 files)',
118 | },
119 | tags: {
120 | type: 'array',
121 | items: {
122 | type: 'string',
123 | },
124 | description: 'Optional tags for the documents',
125 | },
126 | },
127 | },
128 | },
129 | },
130 | },
131 | responses: {
132 | '201': {
133 | description: 'Documents uploaded successfully',
134 | },
135 | },
136 | },
137 | },
138 | },
139 | }
140 |
141 | const converter = new OpenAPIToMCPConverter(spec)
142 | const { tools } = converter.convertToMCPTools()
143 | expect(Object.keys(tools)).toHaveLength(1)
144 |
145 | const [tool] = Object.values(tools)
146 | expect(tool.methods).toHaveLength(1)
147 | const [method] = tool.methods
148 | expect(method.name).toBe('uploadPetDocuments')
149 | expect(method.description).toContain('Upload multiple documents')
150 |
151 | // Check parameters
152 | expect(method.inputSchema.properties).toEqual({
153 | id: {
154 | type: 'integer',
155 | },
156 | documents: {
157 | type: 'array',
158 | items: {
159 | type: 'string',
160 | format: 'uri-reference',
161 | description: 'absolute paths to local files',
162 | },
163 | description: expect.stringContaining('max 5 files'),
164 | },
165 | tags: {
166 | type: 'array',
167 | items: {
168 | type: 'string',
169 | },
170 | description: expect.stringContaining('Optional tags'),
171 | },
172 | })
173 |
174 | expect(method.inputSchema.required).toContain('id')
175 | expect(method.inputSchema.required).toContain('documents')
176 | expect(method.inputSchema.required).not.toContain('tags')
177 | })
178 |
179 | it('handles complex multipart forms with mixed content', () => {
180 | const spec: OpenAPIV3.Document = {
181 | openapi: '3.0.0',
182 | info: { title: 'Test API', version: '1.0.0' },
183 | paths: {
184 | '/pets/{id}/profile': {
185 | post: {
186 | operationId: 'updatePetProfile',
187 | summary: 'Update pet profile with images and data',
188 | parameters: [
189 | {
190 | name: 'id',
191 | in: 'path',
192 | required: true,
193 | schema: { type: 'integer' },
194 | },
195 | ],
196 | requestBody: {
197 | required: true,
198 | content: {
199 | 'multipart/form-data': {
200 | schema: {
201 | type: 'object',
202 | required: ['avatar', 'details'],
203 | properties: {
204 | avatar: {
205 | type: 'string',
206 | format: 'binary',
207 | description: 'Profile picture',
208 | },
209 | gallery: {
210 | type: 'array',
211 | items: {
212 | type: 'string',
213 | format: 'binary',
214 | },
215 | description: 'Additional pet photos',
216 | },
217 | details: {
218 | type: 'object',
219 | properties: {
220 | name: { type: 'string' },
221 | age: { type: 'integer' },
222 | breed: { type: 'string' },
223 | },
224 | },
225 | preferences: {
226 | type: 'array',
227 | items: {
228 | type: 'object',
229 | properties: {
230 | category: { type: 'string' },
231 | value: { type: 'string' },
232 | },
233 | },
234 | },
235 | },
236 | },
237 | },
238 | },
239 | },
240 | responses: {
241 | '200': {
242 | description: 'Profile updated successfully',
243 | },
244 | },
245 | },
246 | },
247 | },
248 | }
249 |
250 | const converter = new OpenAPIToMCPConverter(spec)
251 | const { tools } = converter.convertToMCPTools()
252 | expect(Object.keys(tools)).toHaveLength(1)
253 |
254 | const [tool] = Object.values(tools)
255 | expect(tool.methods).toHaveLength(1)
256 | const [method] = tool.methods
257 | expect(method.name).toBe('updatePetProfile')
258 | expect(method.description).toContain('Update pet profile')
259 |
260 | // Check parameters
261 | expect(method.inputSchema.properties).toEqual({
262 | id: {
263 | type: 'integer',
264 | },
265 | avatar: {
266 | type: 'string',
267 | format: 'uri-reference',
268 | description: expect.stringContaining('Profile picture (absolute paths to local files)'),
269 | },
270 | gallery: {
271 | type: 'array',
272 | items: {
273 | type: 'string',
274 | format: 'uri-reference',
275 | description: 'absolute paths to local files',
276 | },
277 | description: expect.stringContaining('Additional pet photos'),
278 | },
279 | details: {
280 | type: 'object',
281 | properties: {
282 | name: { type: 'string' },
283 | age: { type: 'integer' },
284 | breed: { type: 'string' },
285 | },
286 | additionalProperties: true,
287 | },
288 | preferences: {
289 | type: 'array',
290 | items: {
291 | type: 'object',
292 | properties: {
293 | category: { type: 'string' },
294 | value: { type: 'string' },
295 | },
296 | additionalProperties: true,
297 | },
298 | },
299 | })
300 |
301 | expect(method.inputSchema.required).toContain('id')
302 | expect(method.inputSchema.required).toContain('avatar')
303 | expect(method.inputSchema.required).toContain('details')
304 | expect(method.inputSchema.required).not.toContain('gallery')
305 | expect(method.inputSchema.required).not.toContain('preferences')
306 | })
307 |
308 | it('handles optional file uploads in multipart forms', () => {
309 | const spec: OpenAPIV3.Document = {
310 | openapi: '3.0.0',
311 | info: { title: 'Test API', version: '1.0.0' },
312 | paths: {
313 | '/pets/{id}/metadata': {
314 | post: {
315 | operationId: 'updatePetMetadata',
316 | summary: 'Update pet metadata with optional attachments',
317 | parameters: [
318 | {
319 | name: 'id',
320 | in: 'path',
321 | required: true,
322 | schema: { type: 'integer' },
323 | },
324 | ],
325 | requestBody: {
326 | required: true,
327 | content: {
328 | 'multipart/form-data': {
329 | schema: {
330 | type: 'object',
331 | required: ['metadata'],
332 | properties: {
333 | metadata: {
334 | type: 'object',
335 | required: ['name'],
336 | properties: {
337 | name: { type: 'string' },
338 | description: { type: 'string' },
339 | },
340 | },
341 | certificate: {
342 | type: 'string',
343 | format: 'binary',
344 | description: 'Optional pet certificate',
345 | },
346 | vaccinations: {
347 | type: 'array',
348 | items: {
349 | type: 'string',
350 | format: 'binary',
351 | },
352 | description: 'Optional vaccination records',
353 | },
354 | },
355 | },
356 | },
357 | },
358 | },
359 | responses: {
360 | '200': {
361 | description: 'Metadata updated successfully',
362 | },
363 | },
364 | },
365 | },
366 | },
367 | }
368 |
369 | const converter = new OpenAPIToMCPConverter(spec)
370 | const { tools } = converter.convertToMCPTools()
371 | const [tool] = Object.values(tools)
372 | const [method] = tool.methods
373 |
374 | expect(method.name).toBe('updatePetMetadata')
375 | expect(method.inputSchema.required).toContain('id')
376 | expect(method.inputSchema.required).toContain('metadata')
377 | expect(method.inputSchema.required).not.toContain('certificate')
378 | expect(method.inputSchema.required).not.toContain('vaccinations')
379 |
380 | expect(method.inputSchema.properties).toEqual({
381 | id: {
382 | type: 'integer',
383 | },
384 | metadata: {
385 | type: 'object',
386 | required: ['name'],
387 | properties: {
388 | name: { type: 'string' },
389 | description: { type: 'string' },
390 | },
391 | additionalProperties: true,
392 | },
393 | certificate: {
394 | type: 'string',
395 | format: 'uri-reference',
396 | description: expect.stringContaining('Optional pet certificate (absolute paths to local files)'),
397 | },
398 | vaccinations: {
399 | type: 'array',
400 | items: {
401 | type: 'string',
402 | format: 'uri-reference',
403 | description: 'absolute paths to local files',
404 | },
405 | description: expect.stringContaining('Optional vaccination records'),
406 | },
407 | })
408 | })
409 |
410 | it('handles nested objects with file arrays in multipart forms', () => {
411 | const spec: OpenAPIV3.Document = {
412 | openapi: '3.0.0',
413 | info: { title: 'Test API', version: '1.0.0' },
414 | paths: {
415 | '/pets/{id}/medical-records': {
416 | post: {
417 | operationId: 'addMedicalRecord',
418 | summary: 'Add medical record with attachments',
419 | parameters: [
420 | {
421 | name: 'id',
422 | in: 'path',
423 | required: true,
424 | schema: { type: 'integer' },
425 | },
426 | ],
427 | requestBody: {
428 | required: true,
429 | content: {
430 | 'multipart/form-data': {
431 | schema: {
432 | type: 'object',
433 | required: ['record'],
434 | properties: {
435 | record: {
436 | type: 'object',
437 | required: ['date', 'type'],
438 | properties: {
439 | date: { type: 'string', format: 'date' },
440 | type: { type: 'string' },
441 | notes: { type: 'string' },
442 | attachments: {
443 | type: 'array',
444 | items: {
445 | type: 'object',
446 | required: ['file', 'type'],
447 | properties: {
448 | file: {
449 | type: 'string',
450 | format: 'binary',
451 | },
452 | type: {
453 | type: 'string',
454 | enum: ['xray', 'lab', 'prescription'],
455 | },
456 | description: { type: 'string' },
457 | },
458 | },
459 | },
460 | },
461 | },
462 | },
463 | },
464 | },
465 | },
466 | },
467 | responses: {
468 | '201': {
469 | description: 'Medical record added successfully',
470 | },
471 | },
472 | },
473 | },
474 | },
475 | }
476 |
477 | const converter = new OpenAPIToMCPConverter(spec)
478 | const { tools } = converter.convertToMCPTools()
479 | const [tool] = Object.values(tools)
480 | const [method] = tool.methods
481 |
482 | expect(method.name).toBe('addMedicalRecord')
483 | expect(method.inputSchema.required).toContain('id')
484 | expect(method.inputSchema.required).toContain('record')
485 |
486 | // Verify nested structure is preserved
487 | const recordSchema = method.inputSchema.properties!.record as any
488 | expect(recordSchema.type).toBe('object')
489 | expect(recordSchema.required).toContain('date')
490 | expect(recordSchema.required).toContain('type')
491 |
492 | // Verify nested file array structure
493 | const attachmentsSchema = recordSchema.properties.attachments
494 | expect(attachmentsSchema.type).toBe('array')
495 | expect(attachmentsSchema.items.type).toBe('object')
496 | expect(attachmentsSchema.items.properties.file.format).toBe('uri-reference')
497 | expect(attachmentsSchema.items.properties.file.description).toBe('absolute paths to local files')
498 | expect(attachmentsSchema.items.required).toContain('file')
499 | expect(attachmentsSchema.items.required).toContain('type')
500 | })
501 |
502 | it('handles oneOf/anyOf schemas with file uploads', () => {
503 | const spec: OpenAPIV3.Document = {
504 | openapi: '3.0.0',
505 | info: { title: 'Test API', version: '1.0.0' },
506 | paths: {
507 | '/pets/{id}/content': {
508 | post: {
509 | operationId: 'addPetContent',
510 | summary: 'Add pet content (photo or document)',
511 | parameters: [
512 | {
513 | name: 'id',
514 | in: 'path',
515 | required: true,
516 | schema: { type: 'integer' },
517 | },
518 | ],
519 | requestBody: {
520 | required: true,
521 | content: {
522 | 'multipart/form-data': {
523 | schema: {
524 | type: 'object',
525 | required: ['content'],
526 | properties: {
527 | content: {
528 | oneOf: [
529 | {
530 | type: 'object',
531 | required: ['photo', 'isProfile'],
532 | properties: {
533 | photo: {
534 | type: 'string',
535 | format: 'binary',
536 | },
537 | isProfile: {
538 | type: 'boolean',
539 | },
540 | },
541 | },
542 | {
543 | type: 'object',
544 | required: ['document', 'category'],
545 | properties: {
546 | document: {
547 | type: 'string',
548 | format: 'binary',
549 | },
550 | category: {
551 | type: 'string',
552 | enum: ['medical', 'training', 'adoption'],
553 | },
554 | },
555 | },
556 | ],
557 | },
558 | },
559 | },
560 | },
561 | },
562 | },
563 | responses: {
564 | '201': {
565 | description: 'Content added successfully',
566 | },
567 | },
568 | },
569 | },
570 | },
571 | }
572 |
573 | const converter = new OpenAPIToMCPConverter(spec)
574 | const { tools } = converter.convertToMCPTools()
575 | const [tool] = Object.values(tools)
576 | const [method] = tool.methods
577 |
578 | expect(method.name).toBe('addPetContent')
579 | expect(method.inputSchema.required).toContain('id')
580 | expect(method.inputSchema.required).toContain('content')
581 |
582 | // Verify oneOf structure is preserved
583 | const contentSchema = method.inputSchema.properties!.content as any
584 | expect(contentSchema.oneOf).toHaveLength(2)
585 |
586 | // Check photo option
587 | const photoOption = contentSchema.oneOf[0]
588 | expect(photoOption.type).toBe('object')
589 | expect(photoOption.properties.photo.format).toBe('uri-reference')
590 | expect(photoOption.properties.photo.description).toBe('absolute paths to local files')
591 | expect(photoOption.required).toContain('photo')
592 | expect(photoOption.required).toContain('isProfile')
593 |
594 | // Check document option
595 | const documentOption = contentSchema.oneOf[1]
596 | expect(documentOption.type).toBe('object')
597 | expect(documentOption.properties.document.format).toBe('uri-reference')
598 | expect(documentOption.properties.document.description).toBe('absolute paths to local files')
599 | expect(documentOption.required).toContain('document')
600 | expect(documentOption.required).toContain('category')
601 | })
602 | })
603 |
```