This is page 1 of 2. Use http://codebase.md/makenotion/notion-mcp-server?page={x} to view the full context.
# Directory Structure
```
├── .dockerignore
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── docs
│ └── images
│ ├── connections.png
│ ├── integration-access.png
│ ├── integrations-capabilities.png
│ ├── integrations-creation.png
│ └── page-access-edit.png
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ ├── build-cli.js
│ ├── notion-openapi.json
│ └── start-server.ts
├── smithery.yaml
├── src
│ ├── init-server.ts
│ └── openapi-mcp-server
│ ├── auth
│ │ ├── index.ts
│ │ ├── template.ts
│ │ └── types.ts
│ ├── client
│ │ ├── __tests__
│ │ │ ├── http-client-upload.test.ts
│ │ │ ├── http-client.integration.test.ts
│ │ │ └── http-client.test.ts
│ │ ├── http-client.ts
│ │ └── polyfill-headers.ts
│ ├── index.ts
│ ├── mcp
│ │ ├── __tests__
│ │ │ └── proxy.test.ts
│ │ └── proxy.ts
│ ├── openapi
│ │ ├── __tests__
│ │ │ ├── file-upload.test.ts
│ │ │ ├── parser-multipart.test.ts
│ │ │ └── parser.test.ts
│ │ ├── file-upload.ts
│ │ └── parser.ts
│ └── README.md
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
node_modules
Dockerfile
docker-compose.yml
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
node_modules/
build/
dist
bin/
.cache
.yarn/cache
.eslintcache
.cursor
.DS_Store
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/README.md:
--------------------------------------------------------------------------------
```markdown
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.
Forked to upgrade vulnerable dependencies and easier setup.
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# Notion MCP Server
> [!NOTE]
>
> We’ve introduced **Notion MCP**, a remote MCP server with the following improvements:
> - Easy installation via standard OAuth. No need to fiddle with JSON or API token anymore.
> - Powerful tools tailored to AI agents. These tools are designed with optimized token consumption in mind.
>
> Learn more and try it out [here](https://developers.notion.com/docs/mcp)

This project implements an [MCP server](https://spec.modelcontextprotocol.io/) for the [Notion API](https://developers.notion.com/reference/intro).

### Installation
#### 1. Setting up Integration in Notion:
Go to [https://www.notion.so/profile/integrations](https://www.notion.so/profile/integrations) and create a new **internal** integration or select an existing one.

While we limit the scope of Notion API's exposed (for example, you will not be able to delete databases via MCP), there is a non-zero risk to workspace data by exposing it to LLMs. Security-conscious users may want to further configure the Integration's _Capabilities_.
For example, you can create a read-only integration token by giving only "Read content" access from the "Configuration" tab:

#### 2. Connecting content to integration:
Ensure relevant pages and databases are connected to your integration.
To do this, visit the **Access** tab in your internal integration settings. Edit access and select the pages you'd like to use.


Alternatively, you can grant page access individually. You'll need to visit the target page, and click on the 3 dots, and select "Connect to integration".

#### 3. Adding MCP config to your client:
##### Using npm:
**Cursor & Claude:**
Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json` (MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json`)
**Option 1: Using NOTION_TOKEN (recommended)**
```javascript
{
"mcpServers": {
"notionApi": {
"command": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"env": {
"NOTION_TOKEN": "ntn_****"
}
}
}
}
```
**Option 2: Using OPENAPI_MCP_HEADERS (for advanced use cases)**
```javascript
{
"mcpServers": {
"notionApi": {
"command": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"env": {
"OPENAPI_MCP_HEADERS": "{\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\" }"
}
}
}
}
```
**Zed**
Add the following to your `settings.json`
```json
{
"context_servers": {
"some-context-server": {
"command": {
"path": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"env": {
"OPENAPI_MCP_HEADERS": "{\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\" }"
}
},
"settings": {}
}
}
}
```
##### Using Docker:
There are two options for running the MCP server with Docker:
###### Option 1: Using the official Docker Hub image:
Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json`:
**Using NOTION_TOKEN (recommended):**
```javascript
{
"mcpServers": {
"notionApi": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-e", "NOTION_TOKEN",
"mcp/notion"
],
"env": {
"NOTION_TOKEN": "ntn_****"
}
}
}
}
```
**Using OPENAPI_MCP_HEADERS (for advanced use cases):**
```javascript
{
"mcpServers": {
"notionApi": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-e", "OPENAPI_MCP_HEADERS",
"mcp/notion"
],
"env": {
"OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer ntn_****\",\"Notion-Version\":\"2022-06-28\"}"
}
}
}
}
```
This approach:
- Uses the official Docker Hub image
- Properly handles JSON escaping via environment variables
- Provides a more reliable configuration method
###### Option 2: Building the Docker image locally:
You can also build and run the Docker image locally. First, build the Docker image:
```bash
docker compose build
```
Then, add the following to your `.cursor/mcp.json` or `claude_desktop_config.json`:
**Using NOTION_TOKEN (recommended):**
```javascript
{
"mcpServers": {
"notionApi": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-e",
"NOTION_TOKEN=ntn_****",
"notion-mcp-server"
]
}
}
}
```
**Using OPENAPI_MCP_HEADERS (for advanced use cases):**
```javascript
{
"mcpServers": {
"notionApi": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-e",
"OPENAPI_MCP_HEADERS={\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\"}",
"notion-mcp-server"
]
}
}
}
```
Don't forget to replace `ntn_****` with your integration secret. Find it from your integration configuration tab:

#### Installing via Smithery
[](https://smithery.ai/server/@makenotion/notion-mcp-server)
To install Notion API Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@makenotion/notion-mcp-server):
```bash
npx -y @smithery/cli install @makenotion/notion-mcp-server --client claude
```
### Transport Options
The Notion MCP Server supports two transport modes:
#### STDIO Transport (Default)
The default transport mode uses standard input/output for communication. This is the standard MCP transport used by most clients like Claude Desktop.
```bash
# Run with default stdio transport
npx @notionhq/notion-mcp-server
# Or explicitly specify stdio
npx @notionhq/notion-mcp-server --transport stdio
```
#### Streamable HTTP Transport
For web-based applications or clients that prefer HTTP communication, you can use the Streamable HTTP transport:
```bash
# Run with Streamable HTTP transport on port 3000 (default)
npx @notionhq/notion-mcp-server --transport http
# Run on a custom port
npx @notionhq/notion-mcp-server --transport http --port 8080
# Run with a custom authentication token
npx @notionhq/notion-mcp-server --transport http --auth-token "your-secret-token"
```
When using Streamable HTTP transport, the server will be available at `http://0.0.0.0:<port>/mcp`.
##### Authentication
The Streamable HTTP transport requires bearer token authentication for security. You have three options:
**Option 1: Auto-generated token (recommended for development)**
```bash
npx @notionhq/notion-mcp-server --transport http
```
The server will generate a secure random token and display it in the console:
```
Generated auth token: a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789ab
Use this token in the Authorization header: Bearer a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789ab
```
**Option 2: Custom token via command line (recommended for production)**
```bash
npx @notionhq/notion-mcp-server --transport http --auth-token "your-secret-token"
```
**Option 3: Custom token via environment variable (recommended for production)**
```bash
AUTH_TOKEN="your-secret-token" npx @notionhq/notion-mcp-server --transport http
```
The command line argument `--auth-token` takes precedence over the `AUTH_TOKEN` environment variable if both are provided.
##### Making HTTP Requests
All requests to the Streamable HTTP transport must include the bearer token in the Authorization header:
```bash
# Example request
curl -H "Authorization: Bearer your-token-here" \
-H "Content-Type: application/json" \
-H "mcp-session-id: your-session-id" \
-d '{"jsonrpc": "2.0", "method": "initialize", "params": {}, "id": 1}' \
http://localhost:3000/mcp
```
**Note:** Make sure to set either the `NOTION_TOKEN` environment variable (recommended) or the `OPENAPI_MCP_HEADERS` environment variable with your Notion integration token when using either transport mode.
### Examples
1. Using the following instruction
```
Comment "Hello MCP" on page "Getting started"
```
AI will correctly plan two API calls, `v1/search` and `v1/comments`, to achieve the task
2. Similarly, the following instruction will result in a new page named "Notion MCP" added to parent page "Development"
```
Add a page titled "Notion MCP" to page "Development"
```
3. You may also reference content ID directly
```
Get the content of page 1a6b35e6e67f802fa7e1d27686f017f2
```
### Development
Build
```
npm run build
```
Execute
```
npx -y --prefix /path/to/local/notion-mcp-server @notionhq/notion-mcp-server
```
Publish
```
npm publish --access public
```
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/auth/index.ts:
--------------------------------------------------------------------------------
```typescript
export * from './types'
export * from './template'
```
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
```yaml
services:
notion-mcp-server:
build: .
stdin_open: true
tty: true
restart: unless-stopped
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/index.ts:
--------------------------------------------------------------------------------
```typescript
export { OpenAPIToMCPConverter } from './openapi/parser'
export { HttpClient } from './client/http-client'
export type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/auth/types.ts:
--------------------------------------------------------------------------------
```typescript
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
export interface AuthTemplate {
url: string
method: HttpMethod
headers: Record<string, string>
body?: string
}
export interface SecurityScheme {
[key: string]: {
tokenUrl?: string
[key: string]: any
}
}
export interface Server {
url: string
description?: string
}
export interface TemplateContext {
securityScheme?: SecurityScheme
servers?: Server[]
args: Record<string, string>
}
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./build",
"target": "es2021",
"lib": ["es2022"],
"jsx": "react-jsx",
"module": "es2022",
"moduleResolution": "Bundler",
"types": [
"node"
],
"resolveJsonModule": true,
"allowJs": true,
"checkJs": false,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": [ "test/**/*.ts", "scripts/**/*.ts", "src/**/*.ts"]
}
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/auth/template.ts:
--------------------------------------------------------------------------------
```typescript
import Mustache from 'mustache'
import { AuthTemplate, TemplateContext } from './types'
export function renderAuthTemplate(template: AuthTemplate, context: TemplateContext): AuthTemplate {
// Disable HTML escaping for URLs
Mustache.escape = (text) => text
// Render URL with template variables
const renderedUrl = Mustache.render(template.url, context)
// Create a new template object with rendered values
const renderedTemplate: AuthTemplate = {
...template,
url: renderedUrl,
headers: { ...template.headers }, // Create a new headers object to avoid modifying the original
}
// Render body if it exists
if (template.body) {
renderedTemplate.body = Mustache.render(template.body, context)
}
return renderedTemplate
}
```
--------------------------------------------------------------------------------
/scripts/build-cli.js:
--------------------------------------------------------------------------------
```javascript
import * as esbuild from 'esbuild';
import { chmod } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
async function build() {
await esbuild.build({
entryPoints: [join(__dirname, 'start-server.ts')],
bundle: true,
minify: true,
platform: 'node',
target: 'node18',
format: 'esm',
outfile: 'bin/cli.mjs',
banner: {
js: "#!/usr/bin/env node\nimport { createRequire } from 'module';const require = createRequire(import.meta.url);" // see https://github.com/evanw/esbuild/pull/2067
},
external: ['util'],
});
// Make the output file executable
await chmod('./bin/cli.mjs', 0o755);
}
build().catch((err) => {
console.error(err);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# Generated by https://smithery.ai. See: https://smithery.ai/docs/build/project-config
# syntax=docker/dockerfile:1
# Use Node.js LTS as the base image
FROM node:20-slim AS builder
# Set working directory
WORKDIR /app
# Copy package.json and package-lock.json
COPY package*.json ./
# Install dependencies
RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts --omit-dev
# Copy source code
COPY . .
# Build the package
RUN --mount=type=cache,target=/root/.npm npm run build
# Install package globally
RUN --mount=type=cache,target=/root/.npm npm link
# Minimal image for runtime
FROM node:20-slim
# Copy built package from builder stage
COPY scripts/notion-openapi.json /usr/local/scripts/
COPY --from=builder /usr/local/lib/node_modules/@notionhq/notion-mcp-server /usr/local/lib/node_modules/@notionhq/notion-mcp-server
COPY --from=builder /usr/local/bin/notion-mcp-server /usr/local/bin/notion-mcp-server
# Set default environment variables
ENV OPENAPI_MCP_HEADERS="{}"
# Set entrypoint
ENTRYPOINT ["notion-mcp-server"]
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/polyfill-headers.ts:
--------------------------------------------------------------------------------
```typescript
/*
* The Headers class was supported in Node.js starting with version 18, which was released on April 19, 2022.
* We need to have a polyfill ready to work for old Node versions.
* See more at https://github.com/makenotion/notion-mcp-server/issues/32
* */
class PolyfillHeaders {
private headers: Map<string, string[]> = new Map();
constructor(init?: Record<string, string>) {
if (init) {
Object.entries(init).forEach(([key, value]) => {
this.append(key, value);
});
}
}
public append(name: string, value: string): void {
const key = name.toLowerCase();
if (!this.headers.has(key)) {
this.headers.set(key, []);
}
this.headers.get(key)!.push(value);
}
public get(name: string): string | null {
const key = name.toLowerCase();
if (!this.headers.has(key)) {
return null;
}
return this.headers.get(key)!.join(', ');
}
}
const GlobalHeaders = typeof global !== 'undefined' && 'Headers' in global
? (global as any).Headers
: undefined;
export const Headers = (GlobalHeaders || PolyfillHeaders);
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
# Smithery configuration file: https://smithery.ai/docs/build/project-config
startCommand:
type: stdio
commandFunction:
# A JS function that produces the CLI command based on the given config to start the MCP on stdio.
|-
(config) => {
const env = {};
if (config.notionToken) {
env.NOTION_TOKEN = config.notionToken;
} else if (config.openapiMcpHeaders) {
env.OPENAPI_MCP_HEADERS = config.openapiMcpHeaders;
}
if (config.baseUrl) env.BASE_URL = config.baseUrl;
return { command: 'notion-mcp-server', args: [], env };
}
configSchema:
# JSON Schema defining the configuration options for the MCP.
type: object
anyOf:
- required: [notionToken]
- required: [openapiMcpHeaders]
properties:
notionToken:
type: string
description: Notion integration token (recommended)
openapiMcpHeaders:
type: string
default: "{}"
description: JSON string for HTTP headers, must include Authorization and
Notion-Version (alternative to notionToken)
baseUrl:
type: string
description: Optional override for Notion API base URL
exampleConfig:
notionToken: 'ntn_abcdef'
baseUrl: https://api.notion.com
```
--------------------------------------------------------------------------------
/src/init-server.ts:
--------------------------------------------------------------------------------
```typescript
import fs from 'node:fs'
import path from 'node:path'
import { OpenAPIV3 } from 'openapi-types'
import OpenAPISchemaValidator from 'openapi-schema-validator'
import { MCPProxy } from './openapi-mcp-server/mcp/proxy'
export class ValidationError extends Error {
constructor(public errors: any[]) {
super('OpenAPI validation failed')
this.name = 'ValidationError'
}
}
async function loadOpenApiSpec(specPath: string, baseUrl: string | undefined): Promise<OpenAPIV3.Document> {
let rawSpec: string
try {
rawSpec = fs.readFileSync(path.resolve(process.cwd(), specPath), 'utf-8')
} catch (error) {
console.error('Failed to read OpenAPI specification file:', (error as Error).message)
process.exit(1)
}
// Parse and validate the OpenApi Spec
try {
const parsed = JSON.parse(rawSpec)
// Override baseUrl if specified.
if (baseUrl) {
parsed.servers[0].url = baseUrl
}
return parsed as OpenAPIV3.Document
} catch (error) {
if (error instanceof ValidationError) {
throw error
}
console.error('Failed to parse OpenAPI spec:', (error as Error).message)
process.exit(1)
}
}
export async function initProxy(specPath: string, baseUrl: string |undefined) {
const openApiSpec = await loadOpenApiSpec(specPath, baseUrl)
const proxy = new MCPProxy('Notion API', openApiSpec)
return proxy
}
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/file-upload.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIV3 } from 'openapi-types'
/**
* Identifies file upload parameters in an OpenAPI operation
* @param operation The OpenAPI operation object to check
* @returns Array of parameter names that are file uploads
*/
export function isFileUploadParameter(operation: OpenAPIV3.OperationObject): string[] {
const fileParams: string[] = []
if (!operation.requestBody) return fileParams
const requestBody = operation.requestBody as OpenAPIV3.RequestBodyObject
const content = requestBody.content || {}
// Check multipart/form-data content type for file uploads
const multipartContent = content['multipart/form-data']
if (!multipartContent?.schema) return fileParams
const schema = multipartContent.schema as OpenAPIV3.SchemaObject
if (schema.type !== 'object' || !schema.properties) return fileParams
// Look for properties with type: string, format: binary which indicates file uploads
Object.entries(schema.properties).forEach(([propName, prop]) => {
const schemaProp = prop as OpenAPIV3.SchemaObject
if (schemaProp.type === 'string' && schemaProp.format === 'binary') {
fileParams.push(propName)
}
// Check for array of files
if (schemaProp.type === 'array' && schemaProp.items) {
const itemSchema = schemaProp.items as OpenAPIV3.SchemaObject
if (itemSchema.type === 'string' && itemSchema.format === 'binary') {
fileParams.push(propName)
}
}
})
return fileParams
}
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@notionhq/notion-mcp-server",
"keywords": [
"notion",
"api",
"mcp",
"server"
],
"version": "1.9.0",
"license": "MIT",
"type": "module",
"scripts": {
"build": "tsc -build && node scripts/build-cli.js",
"dev": "tsx watch scripts/start-server.ts"
},
"bin": {
"notion-mcp-server": "bin/cli.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.3",
"axios": "^1.8.4",
"express": "^4.21.2",
"form-data": "^4.0.1",
"mustache": "^4.2.0",
"node-fetch": "^3.3.2",
"openapi-client-axios": "^7.5.5",
"openapi-schema-validator": "^12.1.3",
"openapi-types": "^12.1.3",
"which": "^5.0.0",
"yargs": "^17.7.2",
"zod": "3.24.1"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.33.1",
"@types/express": "^5.0.0",
"@types/js-yaml": "^4.0.9",
"@types/json-schema": "^7.0.15",
"@types/mustache": "^4.2.5",
"@types/node": "^20.17.16",
"@types/which": "^3.0.4",
"@vitest/coverage-v8": "3.1.1",
"esbuild": "^0.25.2",
"multer": "1.4.5-lts.1",
"openai": "^4.91.1",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^3.1.1"
},
"description": "Official MCP server for Notion API",
"main": "index.js",
"repository": {
"type": "git",
"url": "[email protected]:makenotion/notion-mcp-server.git"
},
"author": "@notionhq",
"bugs": {
"url": "https://github.com/makenotion/notion-mcp-server/issues"
},
"homepage": "https://github.com/makenotion/notion-mcp-server#readme"
}
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/__tests__/file-upload.test.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIV3 } from 'openapi-types'
import { describe, it, expect } from 'vitest'
import { isFileUploadParameter } from '../file-upload'
describe('File Upload Detection', () => {
it('identifies file upload parameters in request bodies', () => {
const operation: OpenAPIV3.OperationObject = {
operationId: 'uploadFile',
responses: {
'200': {
description: 'File uploaded successfully',
},
},
requestBody: {
content: {
'multipart/form-data': {
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
},
additionalInfo: {
type: 'string',
},
},
},
},
},
},
}
const fileParams = isFileUploadParameter(operation)
expect(fileParams).toEqual(['file'])
})
it('returns empty array for non-file upload operations', () => {
const operation: OpenAPIV3.OperationObject = {
operationId: 'createUser',
responses: {
'200': {
description: 'User created successfully',
},
},
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
name: {
type: 'string',
},
},
},
},
},
},
}
const fileParams = isFileUploadParameter(operation)
expect(fileParams).toEqual([])
})
it('identifies array-based file upload parameters', () => {
const operation: OpenAPIV3.OperationObject = {
operationId: 'uploadFiles',
responses: {
'200': {
description: 'Files uploaded successfully',
},
},
requestBody: {
content: {
'multipart/form-data': {
schema: {
type: 'object',
properties: {
files: {
type: 'array',
items: {
type: 'string',
format: 'binary',
},
},
description: {
type: 'string',
},
},
},
},
},
},
}
const fileParams = isFileUploadParameter(operation)
expect(fileParams).toEqual(['files'])
})
})
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { HttpClient } from '../http-client'
import type express from 'express'
//@ts-ignore
import { createPetstoreServer } from '../../../examples/petstore-server.cjs'
import type { OpenAPIV3 } from 'openapi-types'
import axios from 'axios'
interface Pet {
id: number
name: string
species: string
age: number
status: 'available' | 'pending' | 'sold'
}
describe('HttpClient Integration Tests', () => {
const PORT = 3456
const BASE_URL = `http://localhost:${PORT}`
let server: ReturnType<typeof express>
let openApiSpec: OpenAPIV3.Document
let client: HttpClient
beforeAll(async () => {
// Start the petstore server
server = createPetstoreServer(PORT) as unknown as express.Express
// Fetch the OpenAPI spec from the server
const response = await axios.get(`${BASE_URL}/openapi.json`)
openApiSpec = response.data
// Create HTTP client
client = new HttpClient(
{
baseUrl: BASE_URL,
headers: {
Accept: 'application/json',
},
},
openApiSpec,
)
})
afterAll(() => {
//@ts-expect-error
server.close()
})
it('should list all pets', async () => {
const operation = openApiSpec.paths['/pets']?.get
if (!operation) throw new Error('Operation not found')
const response = await client.executeOperation<Pet[]>(operation as OpenAPIV3.OperationObject & { method: string; path: string })
expect(response.status).toBe(200)
expect(Array.isArray(response.data)).toBe(true)
expect(response.data.length).toBeGreaterThan(0)
expect(response.data[0]).toHaveProperty('name')
expect(response.data[0]).toHaveProperty('species')
expect(response.data[0]).toHaveProperty('status')
})
it('should filter pets by status', async () => {
const operation = openApiSpec.paths['/pets']?.get as OpenAPIV3.OperationObject & { method: string; path: string }
if (!operation) throw new Error('Operation not found')
const response = await client.executeOperation<Pet[]>(operation, { status: 'available' })
expect(response.status).toBe(200)
expect(Array.isArray(response.data)).toBe(true)
response.data.forEach((pet: Pet) => {
expect(pet.status).toBe('available')
})
})
it('should get a specific pet by ID', async () => {
const operation = openApiSpec.paths['/pets/{id}']?.get as OpenAPIV3.OperationObject & { method: string; path: string }
if (!operation) throw new Error('Operation not found')
const response = await client.executeOperation<Pet>(operation, { id: 1 })
expect(response.status).toBe(200)
expect(response.data).toHaveProperty('id', 1)
expect(response.data).toHaveProperty('name')
expect(response.data).toHaveProperty('species')
})
it('should create a new pet', async () => {
const operation = openApiSpec.paths['/pets']?.post as OpenAPIV3.OperationObject & { method: string; path: string }
if (!operation) throw new Error('Operation not found')
const newPet = {
name: 'TestPet',
species: 'Dog',
age: 2,
}
const response = await client.executeOperation<Pet>(operation as OpenAPIV3.OperationObject & { method: string; path: string }, newPet)
expect(response.status).toBe(201)
expect(response.data).toMatchObject({
...newPet,
status: 'available',
})
expect(response.data.id).toBeDefined()
})
it("should update a pet's status", async () => {
const operation = openApiSpec.paths['/pets/{id}']?.put
if (!operation) throw new Error('Operation not found')
const response = await client.executeOperation<Pet>(operation as OpenAPIV3.OperationObject & { method: string; path: string }, {
id: 1,
status: 'sold',
})
expect(response.status).toBe(200)
expect(response.data).toHaveProperty('id', 1)
expect(response.data).toHaveProperty('status', 'sold')
})
it('should delete a pet', async () => {
// First create a pet to delete
const createOperation = openApiSpec.paths['/pets']?.post
if (!createOperation) throw new Error('Operation not found')
const createResponse = await client.executeOperation<Pet>(
createOperation as OpenAPIV3.OperationObject & { method: string; path: string },
{
name: 'ToDelete',
species: 'Cat',
age: 3,
},
)
const petId = createResponse.data.id
// Then delete it
const deleteOperation = openApiSpec.paths['/pets/{id}']?.delete
if (!deleteOperation) throw new Error('Operation not found')
const deleteResponse = await client.executeOperation(deleteOperation as OpenAPIV3.OperationObject & { method: string; path: string }, {
id: petId,
})
expect(deleteResponse.status).toBe(204)
// Verify the pet is deleted
const getOperation = openApiSpec.paths['/pets/{id}']?.get
if (!getOperation) throw new Error('Operation not found')
try {
await client.executeOperation(getOperation as OpenAPIV3.OperationObject & { method: string; path: string }, { id: petId })
throw new Error('Should not reach here')
} catch (error: any) {
expect(error.message).toContain('404')
}
})
it('should handle errors appropriately', async () => {
const operation = openApiSpec.paths['/pets/{id}']?.get as OpenAPIV3.OperationObject & { method: string; path: string }
if (!operation) throw new Error('Operation not found')
try {
await client.executeOperation(
operation as OpenAPIV3.OperationObject & { method: string; path: string },
{ id: 99999 }, // Non-existent ID
)
throw new Error('Should not reach here')
} catch (error: any) {
expect(error.message).toContain('404')
}
})
})
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/http-client.ts:
--------------------------------------------------------------------------------
```typescript
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
import OpenAPIClientAxios from 'openapi-client-axios'
import type { AxiosInstance } from 'axios'
import FormData from 'form-data'
import fs from 'fs'
import { Headers } from './polyfill-headers'
import { isFileUploadParameter } from '../openapi/file-upload'
export type HttpClientConfig = {
baseUrl: string
headers?: Record<string, string>
}
export type HttpClientResponse<T = any> = {
data: T
status: number
headers: Headers
}
export class HttpClientError extends Error {
constructor(
message: string,
public status: number,
public data: any,
public headers?: Headers,
) {
super(`${status} ${message}`)
this.name = 'HttpClientError'
}
}
export class HttpClient {
private api: Promise<AxiosInstance>
private client: OpenAPIClientAxios
constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {
// @ts-expect-error
this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({
definition: openApiSpec,
axiosConfigDefaults: {
baseURL: config.baseUrl,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'notion-mcp-server',
...config.headers,
},
},
})
this.api = this.client.init()
}
private async prepareFileUpload(operation: OpenAPIV3.OperationObject, params: Record<string, any>): Promise<FormData | null> {
const fileParams = isFileUploadParameter(operation)
if (fileParams.length === 0) return null
const formData = new FormData()
// Handle file uploads
for (const param of fileParams) {
const filePath = params[param]
if (!filePath) {
throw new Error(`File path must be provided for parameter: ${param}`)
}
switch (typeof filePath) {
case 'string':
addFile(param, filePath)
break
case 'object':
if(Array.isArray(filePath)) {
let fileCount = 0
for(const file of filePath) {
addFile(param, file)
fileCount++
}
break
}
//deliberate fallthrough
default:
throw new Error(`Unsupported file type: ${typeof filePath}`)
}
function addFile(name: string, filePath: string) {
try {
const fileStream = fs.createReadStream(filePath)
formData.append(name, fileStream)
} catch (error) {
throw new Error(`Failed to read file at ${filePath}: ${error}`)
}
}
}
// Add non-file parameters to form data
for (const [key, value] of Object.entries(params)) {
if (!fileParams.includes(key)) {
formData.append(key, value)
}
}
return formData
}
/**
* Execute an OpenAPI operation
*/
async executeOperation<T = any>(
operation: OpenAPIV3.OperationObject & { method: string; path: string },
params: Record<string, any> = {},
): Promise<HttpClientResponse<T>> {
const api = await this.api
const operationId = operation.operationId
if (!operationId) {
throw new Error('Operation ID is required')
}
// Handle file uploads if present
const formData = await this.prepareFileUpload(operation, params)
// Separate parameters based on their location
const urlParameters: Record<string, any> = {}
const bodyParams: Record<string, any> = formData || { ...params }
// Extract path and query parameters based on operation definition
if (operation.parameters) {
for (const param of operation.parameters) {
if ('name' in param && param.name && param.in) {
if (param.in === 'path' || param.in === 'query') {
if (params[param.name] !== undefined) {
urlParameters[param.name] = params[param.name]
if (!formData) {
delete bodyParams[param.name]
}
}
}
}
}
}
// Add all parameters as url parameters if there is no requestBody defined
if (!operation.requestBody && !formData) {
for (const key in bodyParams) {
if (bodyParams[key] !== undefined) {
urlParameters[key] = bodyParams[key]
delete bodyParams[key]
}
}
}
const operationFn = (api as any)[operationId]
if (!operationFn) {
throw new Error(`Operation ${operationId} not found`)
}
try {
// If we have form data, we need to set the correct headers
const hasBody = Object.keys(bodyParams).length > 0
const headers = formData
? formData.getHeaders()
: { ...(hasBody ? { 'Content-Type': 'application/json' } : { 'Content-Type': null }) }
const requestConfig = {
headers: {
...headers,
},
}
// first argument is url parameters, second is body parameters
const response = await operationFn(urlParameters, hasBody ? bodyParams : undefined, requestConfig)
// Convert axios headers to Headers object
const responseHeaders = new Headers()
Object.entries(response.headers).forEach(([key, value]) => {
if (value) responseHeaders.append(key, value.toString())
})
return {
data: response.data,
status: response.status,
headers: responseHeaders,
}
} catch (error: any) {
if (error.response) {
console.error('Error in http client', error)
const headers = new Headers()
Object.entries(error.response.headers).forEach(([key, value]) => {
if (value) headers.append(key, value.toString())
})
throw new HttpClientError(error.response.statusText || 'Request failed', error.response.status, error.response.data, headers)
}
throw error
}
}
}
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/mcp/proxy.ts:
--------------------------------------------------------------------------------
```typescript
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, JSONRPCResponse, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
import { JSONSchema7 as IJsonSchema } from 'json-schema'
import { OpenAPIToMCPConverter } from '../openapi/parser'
import { HttpClient, HttpClientError } from '../client/http-client'
import { OpenAPIV3 } from 'openapi-types'
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
type PathItemObject = OpenAPIV3.PathItemObject & {
get?: OpenAPIV3.OperationObject
put?: OpenAPIV3.OperationObject
post?: OpenAPIV3.OperationObject
delete?: OpenAPIV3.OperationObject
patch?: OpenAPIV3.OperationObject
}
type NewToolDefinition = {
methods: Array<{
name: string
description: string
inputSchema: IJsonSchema & { type: 'object' }
returnSchema?: IJsonSchema
}>
}
// import this class, extend and return server
export class MCPProxy {
private server: Server
private httpClient: HttpClient
private tools: Record<string, NewToolDefinition>
private openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
constructor(name: string, openApiSpec: OpenAPIV3.Document) {
this.server = new Server({ name, version: '1.0.0' }, { capabilities: { tools: {} } })
const baseUrl = openApiSpec.servers?.[0].url
if (!baseUrl) {
throw new Error('No base URL found in OpenAPI spec')
}
this.httpClient = new HttpClient(
{
baseUrl,
headers: this.parseHeadersFromEnv(),
},
openApiSpec,
)
// Convert OpenAPI spec to MCP tools
const converter = new OpenAPIToMCPConverter(openApiSpec)
const { tools, openApiLookup } = converter.convertToMCPTools()
this.tools = tools
this.openApiLookup = openApiLookup
this.setupHandlers()
}
private setupHandlers() {
// Handle tool listing
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools: Tool[] = []
// Add methods as separate tools to match the MCP format
Object.entries(this.tools).forEach(([toolName, def]) => {
def.methods.forEach(method => {
const toolNameWithMethod = `${toolName}-${method.name}`;
const truncatedToolName = this.truncateToolName(toolNameWithMethod);
tools.push({
name: truncatedToolName,
description: method.description,
inputSchema: method.inputSchema as Tool['inputSchema'],
})
})
})
return { tools }
})
// Handle tool calling
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: params } = request.params
// Find the operation in OpenAPI spec
const operation = this.findOperation(name)
if (!operation) {
throw new Error(`Method ${name} not found`)
}
try {
// Execute the operation
const response = await this.httpClient.executeOperation(operation, params)
// Convert response to MCP format
return {
content: [
{
type: 'text', // currently this is the only type that seems to be used by mcp server
text: JSON.stringify(response.data), // TODO: pass through the http status code text?
},
],
}
} catch (error) {
console.error('Error in tool call', error)
if (error instanceof HttpClientError) {
console.error('HttpClientError encountered, returning structured error', error)
const data = error.data?.response?.data ?? error.data ?? {}
return {
content: [
{
type: 'text',
text: JSON.stringify({
status: 'error', // TODO: get this from http status code?
...(typeof data === 'object' ? data : { data: data }),
}),
},
],
}
}
throw error
}
})
}
private findOperation(operationId: string): (OpenAPIV3.OperationObject & { method: string; path: string }) | null {
return this.openApiLookup[operationId] ?? null
}
private parseHeadersFromEnv(): Record<string, string> {
// First try OPENAPI_MCP_HEADERS (existing behavior)
const headersJson = process.env.OPENAPI_MCP_HEADERS
if (headersJson) {
try {
const headers = JSON.parse(headersJson)
if (typeof headers !== 'object' || headers === null) {
console.warn('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', typeof headers)
} else if (Object.keys(headers).length > 0) {
// Only use OPENAPI_MCP_HEADERS if it contains actual headers
return headers
}
// If OPENAPI_MCP_HEADERS is empty object, fall through to try NOTION_TOKEN
} catch (error) {
console.warn('Failed to parse OPENAPI_MCP_HEADERS environment variable:', error)
// Fall through to try NOTION_TOKEN
}
}
// Alternative: try NOTION_TOKEN
const notionToken = process.env.NOTION_TOKEN
if (notionToken) {
return {
'Authorization': `Bearer ${notionToken}`,
'Notion-Version': '2022-06-28'
}
}
return {}
}
private getContentType(headers: Headers): 'text' | 'image' | 'binary' {
const contentType = headers.get('content-type')
if (!contentType) return 'binary'
if (contentType.includes('text') || contentType.includes('json')) {
return 'text'
} else if (contentType.includes('image')) {
return 'image'
}
return 'binary'
}
private truncateToolName(name: string): string {
if (name.length <= 64) {
return name;
}
return name.slice(0, 64);
}
async connect(transport: Transport) {
// The SDK will handle stdio communication
await this.server.connect(transport)
}
getServer() {
return this.server
}
}
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/__tests__/http-client-upload.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { HttpClient } from '../http-client'
import { OpenAPIV3 } from 'openapi-types'
import fs from 'fs'
import FormData from 'form-data'
vi.mock('fs')
vi.mock('form-data')
describe('HttpClient File Upload', () => {
let client: HttpClient
const mockApiInstance = {
uploadFile: vi.fn(),
}
const baseConfig = {
baseUrl: 'http://test.com',
headers: {},
}
const mockOpenApiSpec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: {
title: 'Test API',
version: '1.0.0',
},
paths: {
'/upload': {
post: {
operationId: 'uploadFile',
responses: {
'200': {
description: 'File uploaded successfully',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: {
type: 'boolean',
},
},
},
},
},
},
},
requestBody: {
content: {
'multipart/form-data': {
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
},
description: {
type: 'string',
},
},
},
},
},
},
},
},
},
}
beforeEach(() => {
vi.clearAllMocks()
client = new HttpClient(baseConfig, mockOpenApiSpec)
// @ts-expect-error - Mock the private api property
client['api'] = Promise.resolve(mockApiInstance)
})
it('should handle file uploads with FormData', async () => {
const mockFormData = new FormData()
const mockFileStream = { pipe: vi.fn() }
const mockFormDataHeaders = { 'content-type': 'multipart/form-data; boundary=---123' }
vi.mocked(fs.createReadStream).mockReturnValue(mockFileStream as any)
vi.mocked(FormData.prototype.append).mockImplementation(() => {})
vi.mocked(FormData.prototype.getHeaders).mockReturnValue(mockFormDataHeaders)
const uploadPath = mockOpenApiSpec.paths['/upload']
if (!uploadPath?.post) {
throw new Error('Upload path not found in spec')
}
const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string }
const params = {
file: '/path/to/test.txt',
description: 'Test file',
}
mockApiInstance.uploadFile.mockResolvedValue({
data: { success: true },
status: 200,
headers: {},
})
await client.executeOperation(operation, params)
expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test.txt')
expect(FormData.prototype.append).toHaveBeenCalledWith('file', mockFileStream)
expect(FormData.prototype.append).toHaveBeenCalledWith('description', 'Test file')
expect(mockApiInstance.uploadFile).toHaveBeenCalledWith({}, expect.any(FormData), { headers: mockFormDataHeaders })
})
it('should throw error for invalid file path', async () => {
vi.mocked(fs.createReadStream).mockImplementation(() => {
throw new Error('File not found')
})
const uploadPath = mockOpenApiSpec.paths['/upload']
if (!uploadPath?.post) {
throw new Error('Upload path not found in spec')
}
const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string }
const params = {
file: '/nonexistent/file.txt',
description: 'Test file',
}
await expect(client.executeOperation(operation, params)).rejects.toThrow('Failed to read file at /nonexistent/file.txt')
})
it('should handle multiple file uploads', async () => {
const mockFormData = new FormData()
const mockFileStream1 = { pipe: vi.fn() }
const mockFileStream2 = { pipe: vi.fn() }
const mockFormDataHeaders = { 'content-type': 'multipart/form-data; boundary=---123' }
vi.mocked(fs.createReadStream)
.mockReturnValueOnce(mockFileStream1 as any)
.mockReturnValueOnce(mockFileStream2 as any)
vi.mocked(FormData.prototype.append).mockImplementation(() => {})
vi.mocked(FormData.prototype.getHeaders).mockReturnValue(mockFormDataHeaders)
const operation: OpenAPIV3.OperationObject = {
operationId: 'uploadFile',
responses: {
'200': {
description: 'Files uploaded successfully',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: {
type: 'boolean',
},
},
},
},
},
},
},
requestBody: {
content: {
'multipart/form-data': {
schema: {
type: 'object',
properties: {
file1: {
type: 'string',
format: 'binary',
},
file2: {
type: 'string',
format: 'binary',
},
description: {
type: 'string',
},
},
},
},
},
},
}
const params = {
file1: '/path/to/test1.txt',
file2: '/path/to/test2.txt',
description: 'Test files',
}
mockApiInstance.uploadFile.mockResolvedValue({
data: { success: true },
status: 200,
headers: {},
})
await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, params)
expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test1.txt')
expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test2.txt')
expect(FormData.prototype.append).toHaveBeenCalledWith('file1', mockFileStream1)
expect(FormData.prototype.append).toHaveBeenCalledWith('file2', mockFileStream2)
expect(FormData.prototype.append).toHaveBeenCalledWith('description', 'Test files')
expect(mockApiInstance.uploadFile).toHaveBeenCalledWith({}, expect.any(FormData), { headers: mockFormDataHeaders })
})
})
```
--------------------------------------------------------------------------------
/scripts/start-server.ts:
--------------------------------------------------------------------------------
```typescript
import path from 'node:path'
import { fileURLToPath } from 'url'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
import { randomUUID, randomBytes } from 'node:crypto'
import express from 'express'
import { initProxy, ValidationError } from '../src/init-server'
export async function startServer(args: string[] = process.argv) {
const filename = fileURLToPath(import.meta.url)
const directory = path.dirname(filename)
const specPath = path.resolve(directory, '../scripts/notion-openapi.json')
const baseUrl = process.env.BASE_URL ?? undefined
// Parse command line arguments manually (similar to slack-mcp approach)
function parseArgs() {
const args = process.argv.slice(2);
let transport = 'stdio'; // default
let port = 3000;
let authToken: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--transport' && i + 1 < args.length) {
transport = args[i + 1];
i++; // skip next argument
} else if (args[i] === '--port' && i + 1 < args.length) {
port = parseInt(args[i + 1], 10);
i++; // skip next argument
} else if (args[i] === '--auth-token' && i + 1 < args.length) {
authToken = args[i + 1];
i++; // skip next argument
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Usage: notion-mcp-server [options]
Options:
--transport <type> Transport type: 'stdio' or 'http' (default: stdio)
--port <number> Port for HTTP server when using Streamable HTTP transport (default: 3000)
--auth-token <token> Bearer token for HTTP transport authentication (optional)
--help, -h Show this help message
Environment Variables:
NOTION_TOKEN Notion integration token (recommended)
OPENAPI_MCP_HEADERS JSON string with Notion API headers (alternative)
AUTH_TOKEN Bearer token for HTTP transport authentication (alternative to --auth-token)
Examples:
notion-mcp-server # Use stdio transport (default)
notion-mcp-server --transport stdio # Use stdio transport explicitly
notion-mcp-server --transport http # Use Streamable HTTP transport on port 3000
notion-mcp-server --transport http --port 8080 # Use Streamable HTTP transport on port 8080
notion-mcp-server --transport http --auth-token mytoken # Use Streamable HTTP transport with custom auth token
AUTH_TOKEN=mytoken notion-mcp-server --transport http # Use Streamable HTTP transport with auth token from env var
`);
process.exit(0);
}
// Ignore unrecognized arguments (like command name passed by Docker)
}
return { transport: transport.toLowerCase(), port, authToken };
}
const options = parseArgs()
const transport = options.transport
if (transport === 'stdio') {
// Use stdio transport (default)
const proxy = await initProxy(specPath, baseUrl)
await proxy.connect(new StdioServerTransport())
return proxy.getServer()
} else if (transport === 'http') {
// Use Streamable HTTP transport
const app = express()
app.use(express.json())
// Generate or use provided auth token (from CLI arg or env var)
const authToken = options.authToken || process.env.AUTH_TOKEN || randomBytes(32).toString('hex')
if (!options.authToken && !process.env.AUTH_TOKEN) {
console.log(`Generated auth token: ${authToken}`)
console.log(`Use this token in the Authorization header: Bearer ${authToken}`)
}
// Authorization middleware
const authenticateToken = (req: express.Request, res: express.Response, next: express.NextFunction): void => {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1] // Bearer TOKEN
if (!token) {
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized: Missing bearer token',
},
id: null,
})
return
}
if (token !== authToken) {
res.status(403).json({
jsonrpc: '2.0',
error: {
code: -32002,
message: 'Forbidden: Invalid bearer token',
},
id: null,
})
return
}
next()
}
// Health endpoint (no authentication required)
app.get('/health', (req, res) => {
res.status(200).json({
status: 'healthy',
timestamp: new Date().toISOString(),
transport: 'http',
port: options.port
})
})
// Apply authentication to all /mcp routes
app.use('/mcp', authenticateToken)
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
// Handle POST requests for client-to-server communication
app.post('/mcp', async (req, res) => {
try {
// Check for existing session ID
const sessionId = req.headers['mcp-session-id'] as string | undefined
let transport: StreamableHTTPServerTransport
if (sessionId && transports[sessionId]) {
// Reuse existing transport
transport = transports[sessionId]
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
// Store the transport by session ID
transports[sessionId] = transport
}
})
// Clean up transport when closed
transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId]
}
}
const proxy = await initProxy(specPath, baseUrl)
await proxy.connect(transport)
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
})
return
}
// Handle the request
await transport.handleRequest(req, res, req.body)
} catch (error) {
console.error('Error handling MCP request:', error)
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
})
}
}
})
// Handle GET requests for server-to-client notifications via Streamable HTTP
app.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID')
return
}
const transport = transports[sessionId]
await transport.handleRequest(req, res)
})
// Handle DELETE requests for session termination
app.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID')
return
}
const transport = transports[sessionId]
await transport.handleRequest(req, res)
})
const port = options.port
app.listen(port, '0.0.0.0', () => {
console.log(`MCP Server listening on port ${port}`)
console.log(`Endpoint: http://0.0.0.0:${port}/mcp`)
console.log(`Health check: http://0.0.0.0:${port}/health`)
console.log(`Authentication: Bearer token required`)
if (options.authToken) {
console.log(`Using provided auth token`)
}
})
// Return a dummy server for compatibility
return { close: () => {} }
} else {
throw new Error(`Unsupported transport: ${transport}. Use 'stdio' or 'http'.`)
}
}
startServer(process.argv).catch(error => {
if (error instanceof ValidationError) {
console.error('Invalid OpenAPI 3.1 specification:')
error.errors.forEach(err => console.error(err))
} else {
console.error('Error:', error)
}
process.exit(1)
})
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts:
--------------------------------------------------------------------------------
```typescript
import { MCPProxy } from '../proxy'
import { OpenAPIV3 } from 'openapi-types'
import { HttpClient } from '../../client/http-client'
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
// Mock the dependencies
vi.mock('../../client/http-client')
vi.mock('@modelcontextprotocol/sdk/server/index.js')
describe('MCPProxy', () => {
let proxy: MCPProxy
let mockOpenApiSpec: OpenAPIV3.Document
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
// Setup minimal OpenAPI spec for testing
mockOpenApiSpec = {
openapi: '3.0.0',
servers: [{ url: 'http://localhost:3000' }],
info: {
title: 'Test API',
version: '1.0.0',
},
paths: {
'/test': {
get: {
operationId: 'getTest',
responses: {
'200': {
description: 'Success',
},
},
},
},
},
}
proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
})
describe('listTools handler', () => {
it('should return converted tools from OpenAPI spec', async () => {
const server = (proxy as any).server
const listToolsHandler = server.setRequestHandler.mock.calls[0].filter((x: unknown) => typeof x === 'function')[0]
const result = await listToolsHandler()
expect(result).toHaveProperty('tools')
expect(Array.isArray(result.tools)).toBe(true)
})
it('should truncate tool names exceeding 64 characters', async () => {
// Setup OpenAPI spec with long tool names
mockOpenApiSpec.paths = {
'/test': {
get: {
operationId: 'a'.repeat(65),
responses: {
'200': {
description: 'Success'
}
}
}
}
}
proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
const server = (proxy as any).server
const listToolsHandler = server.setRequestHandler.mock.calls[0].filter((x: unknown) => typeof x === 'function')[0];
const result = await listToolsHandler()
expect(result.tools[0].name.length).toBeLessThanOrEqual(64)
})
})
describe('callTool handler', () => {
it('should execute operation and return formatted response', async () => {
// Mock HttpClient response
const mockResponse = {
data: { message: 'success' },
status: 200,
headers: new Headers({
'content-type': 'application/json',
}),
}
;(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse)
// Set up the openApiLookup with our test operation
;(proxy as any).openApiLookup = {
'API-getTest': {
operationId: 'getTest',
responses: { '200': { description: 'Success' } },
method: 'get',
path: '/test',
},
}
const server = (proxy as any).server
const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
const callToolHandler = handlers[1]
const result = await callToolHandler({
params: {
name: 'API-getTest',
arguments: {},
},
})
expect(result).toEqual({
content: [
{
type: 'text',
text: JSON.stringify({ message: 'success' }),
},
],
})
})
it('should throw error for non-existent operation', async () => {
const server = (proxy as any).server
const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
const callToolHandler = handlers[1]
await expect(
callToolHandler({
params: {
name: 'nonExistentMethod',
arguments: {},
},
}),
).rejects.toThrow('Method nonExistentMethod not found')
})
it('should handle tool names exceeding 64 characters', async () => {
// Mock HttpClient response
const mockResponse = {
data: { message: 'success' },
status: 200,
headers: new Headers({
'content-type': 'application/json'
})
};
(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
// Set up the openApiLookup with a long tool name
const longToolName = 'a'.repeat(65)
const truncatedToolName = longToolName.slice(0, 64)
;(proxy as any).openApiLookup = {
[truncatedToolName]: {
operationId: longToolName,
responses: { '200': { description: 'Success' } },
method: 'get',
path: '/test'
}
};
const server = (proxy as any).server;
const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function');
const callToolHandler = handlers[1];
const result = await callToolHandler({
params: {
name: truncatedToolName,
arguments: {}
}
})
expect(result).toEqual({
content: [
{
type: 'text',
text: JSON.stringify({ message: 'success' })
}
]
})
})
})
describe('getContentType', () => {
it('should return correct content type for different headers', () => {
const getContentType = (proxy as any).getContentType.bind(proxy)
expect(getContentType(new Headers({ 'content-type': 'text/plain' }))).toBe('text')
expect(getContentType(new Headers({ 'content-type': 'application/json' }))).toBe('text')
expect(getContentType(new Headers({ 'content-type': 'image/jpeg' }))).toBe('image')
expect(getContentType(new Headers({ 'content-type': 'application/octet-stream' }))).toBe('binary')
expect(getContentType(new Headers())).toBe('binary')
})
})
describe('parseHeadersFromEnv', () => {
const originalEnv = process.env
beforeEach(() => {
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = originalEnv
})
it('should parse valid JSON headers from env', () => {
process.env.OPENAPI_MCP_HEADERS = JSON.stringify({
Authorization: 'Bearer token123',
'X-Custom-Header': 'test',
})
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
Authorization: 'Bearer token123',
'X-Custom-Header': 'test',
},
}),
expect.anything(),
)
})
it('should return empty object when env var is not set', () => {
delete process.env.OPENAPI_MCP_HEADERS
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({
headers: {},
}),
expect.anything(),
)
})
it('should return empty object and warn on invalid JSON', () => {
const consoleSpy = vi.spyOn(console, 'warn')
process.env.OPENAPI_MCP_HEADERS = 'invalid json'
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({
headers: {},
}),
expect.anything(),
)
expect(consoleSpy).toHaveBeenCalledWith('Failed to parse OPENAPI_MCP_HEADERS environment variable:', expect.any(Error))
})
it('should return empty object and warn on non-object JSON', () => {
const consoleSpy = vi.spyOn(console, 'warn')
process.env.OPENAPI_MCP_HEADERS = '"string"'
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({
headers: {},
}),
expect.anything(),
)
expect(consoleSpy).toHaveBeenCalledWith('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', 'string')
})
it('should use NOTION_TOKEN when OPENAPI_MCP_HEADERS is not set', () => {
delete process.env.OPENAPI_MCP_HEADERS
process.env.NOTION_TOKEN = 'ntn_test_token_123'
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
'Authorization': 'Bearer ntn_test_token_123',
'Notion-Version': '2022-06-28'
},
}),
expect.anything(),
)
})
it('should prioritize OPENAPI_MCP_HEADERS over NOTION_TOKEN when both are set', () => {
process.env.OPENAPI_MCP_HEADERS = JSON.stringify({
Authorization: 'Bearer custom_token',
'Custom-Header': 'custom_value',
})
process.env.NOTION_TOKEN = 'ntn_test_token_123'
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
Authorization: 'Bearer custom_token',
'Custom-Header': 'custom_value',
},
}),
expect.anything(),
)
})
it('should return empty object when neither OPENAPI_MCP_HEADERS nor NOTION_TOKEN are set', () => {
delete process.env.OPENAPI_MCP_HEADERS
delete process.env.NOTION_TOKEN
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({
headers: {},
}),
expect.anything(),
)
})
it('should use NOTION_TOKEN when OPENAPI_MCP_HEADERS is empty object', () => {
process.env.OPENAPI_MCP_HEADERS = '{}'
process.env.NOTION_TOKEN = 'ntn_test_token_123'
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
'Authorization': 'Bearer ntn_test_token_123',
'Notion-Version': '2022-06-28'
},
}),
expect.anything(),
)
})
})
describe('connect', () => {
it('should connect to transport', async () => {
const mockTransport = {} as Transport
await proxy.connect(mockTransport)
const server = (proxy as any).server
expect(server.connect).toHaveBeenCalledWith(mockTransport)
})
})
})
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/__tests__/http-client.test.ts:
--------------------------------------------------------------------------------
```typescript
import { HttpClient, HttpClientError } from '../http-client'
import { OpenAPIV3 } from 'openapi-types'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import OpenAPIClientAxios from 'openapi-client-axios'
// Mock the OpenAPIClientAxios initialization
vi.mock('openapi-client-axios', () => {
const mockApi = {
getPet: vi.fn(),
testOperation: vi.fn(),
complexOperation: vi.fn(),
}
return {
default: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(mockApi),
})),
}
})
describe('HttpClient', () => {
let client: HttpClient
let mockApi: any
const sampleSpec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/pets/{petId}': {
get: {
operationId: 'getPet',
parameters: [
{
name: 'petId',
in: 'path',
required: true,
schema: { type: 'integer' },
},
],
responses: {
'200': {
description: 'OK',
content: {
'application/json': {
schema: { type: 'object' },
},
},
},
},
},
},
},
}
const getPetOperation = sampleSpec.paths['/pets/{petId}']?.get as OpenAPIV3.OperationObject & { method: string; path: string }
if (!getPetOperation) {
throw new Error('Test setup error: getPet operation not found in sample spec')
}
beforeEach(async () => {
// Create a new instance of HttpClient
client = new HttpClient({ baseUrl: 'https://api.example.com' }, sampleSpec)
// Await the initialization to ensure mockApi is set correctly
mockApi = await client['api']
})
afterEach(() => {
vi.clearAllMocks()
})
it('successfully executes an operation', async () => {
const mockResponse = {
data: { id: 1, name: 'Fluffy' },
status: 200,
headers: {
'content-type': 'application/json',
},
}
mockApi.getPet.mockResolvedValueOnce(mockResponse)
const response = await client.executeOperation(getPetOperation, { petId: 1 })
// Note GET requests should have a null Content-Type header!
expect(mockApi.getPet).toHaveBeenCalledWith({ petId: 1 }, undefined, { headers: { 'Content-Type': null } })
expect(response.data).toEqual(mockResponse.data)
expect(response.status).toBe(200)
expect(response.headers).toBeInstanceOf(Headers)
expect(response.headers.get('content-type')).toBe('application/json')
})
it('throws error when operation ID is missing', async () => {
const operationWithoutId: OpenAPIV3.OperationObject & { method: string; path: string } = {
method: 'GET',
path: '/unknown',
responses: {
'200': {
description: 'OK',
},
},
}
await expect(client.executeOperation(operationWithoutId)).rejects.toThrow('Operation ID is required')
})
it('throws error when operation is not found', async () => {
const operation: OpenAPIV3.OperationObject & { method: string; path: string } = {
method: 'GET',
path: '/unknown',
operationId: 'nonexistentOperation',
responses: {
'200': {
description: 'OK',
},
},
}
await expect(client.executeOperation(operation)).rejects.toThrow('Operation nonexistentOperation not found')
})
it('handles API errors correctly', async () => {
const error = {
response: {
status: 404,
statusText: 'Not Found',
data: {
code: 'RESOURCE_NOT_FOUND',
message: 'Pet not found',
petId: 999,
},
headers: {
'content-type': 'application/json',
},
},
}
mockApi.getPet.mockRejectedValueOnce(error)
await expect(client.executeOperation(getPetOperation, { petId: 999 })).rejects.toMatchObject({
status: 404,
message: '404 Not Found',
data: {
code: 'RESOURCE_NOT_FOUND',
message: 'Pet not found',
petId: 999,
},
})
})
it('handles validation errors (400) correctly', async () => {
const error = {
response: {
status: 400,
statusText: 'Bad Request',
data: {
code: 'VALIDATION_ERROR',
message: 'Invalid input data',
errors: [
{
field: 'age',
message: 'Age must be a positive number',
},
{
field: 'name',
message: 'Name is required',
},
],
},
headers: {
'content-type': 'application/json',
},
},
}
mockApi.getPet.mockRejectedValueOnce(error)
await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({
status: 400,
message: '400 Bad Request',
data: {
code: 'VALIDATION_ERROR',
message: 'Invalid input data',
errors: [
{
field: 'age',
message: 'Age must be a positive number',
},
{
field: 'name',
message: 'Name is required',
},
],
},
})
})
it('handles server errors (500) with HTML response', async () => {
const error = {
response: {
status: 500,
statusText: 'Internal Server Error',
data: '<html><body><h1>500 Internal Server Error</h1></body></html>',
headers: {
'content-type': 'text/html',
},
},
}
mockApi.getPet.mockRejectedValueOnce(error)
await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({
status: 500,
message: '500 Internal Server Error',
data: '<html><body><h1>500 Internal Server Error</h1></body></html>',
})
})
it('handles rate limit errors (429)', async () => {
const error = {
response: {
status: 429,
statusText: 'Too Many Requests',
data: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Rate limit exceeded',
retryAfter: 60,
},
headers: {
'content-type': 'application/json',
'retry-after': '60',
},
},
}
mockApi.getPet.mockRejectedValueOnce(error)
await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({
status: 429,
message: '429 Too Many Requests',
data: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Rate limit exceeded',
retryAfter: 60,
},
})
})
it('should send body parameters in request body for POST operations', async () => {
// Setup mock API with the new operation
mockApi.testOperation = vi.fn().mockResolvedValue({
data: {},
status: 200,
headers: {},
})
const testSpec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/test': {
post: {
operationId: 'testOperation',
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
foo: { type: 'string' },
},
},
},
},
},
responses: {
'200': {
description: 'Success response',
content: {
'application/json': {
schema: {
type: 'object',
},
},
},
},
},
},
},
},
}
const postOperation = testSpec.paths['/test']?.post as OpenAPIV3.OperationObject & { method: string; path: string }
if (!postOperation) {
throw new Error('Test setup error: post operation not found')
}
const client = new HttpClient({ baseUrl: 'http://test.com' }, testSpec)
await client.executeOperation(postOperation, { foo: 'bar' })
expect(mockApi.testOperation).toHaveBeenCalledWith({}, { foo: 'bar' }, { headers: { 'Content-Type': 'application/json' } })
})
it('should handle query, path, and body parameters correctly', async () => {
mockApi.complexOperation = vi.fn().mockResolvedValue({
data: { success: true },
status: 200,
headers: {
'content-type': 'application/json',
},
})
const complexSpec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/users/{userId}/posts': {
post: {
operationId: 'complexOperation',
parameters: [
{
name: 'userId',
in: 'path',
required: true,
schema: { type: 'integer' },
},
{
name: 'include',
in: 'query',
required: false,
schema: { type: 'string' },
},
],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
title: { type: 'string' },
content: { type: 'string' },
},
},
},
},
},
responses: {
'200': {
description: 'Success response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
},
},
},
},
},
},
},
},
},
}
const complexOperation = complexSpec.paths['/users/{userId}/posts']?.post as OpenAPIV3.OperationObject & {
method: string
path: string
}
if (!complexOperation) {
throw new Error('Test setup error: complex operation not found')
}
const client = new HttpClient({ baseUrl: 'http://test.com' }, complexSpec)
await client.executeOperation(complexOperation, {
// Path parameter
userId: 123,
// Query parameter
include: 'comments',
// Body parameters
title: 'Test Post',
content: 'Test Content',
})
expect(mockApi.complexOperation).toHaveBeenCalledWith(
{
userId: 123,
include: 'comments',
},
{
title: 'Test Post',
content: 'Test Content',
},
{ headers: { 'Content-Type': 'application/json' } },
)
})
const mockOpenApiSpec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/test': {
post: {
operationId: 'testOperation',
parameters: [
{
name: 'queryParam',
in: 'query',
schema: { type: 'string' },
},
{
name: 'pathParam',
in: 'path',
schema: { type: 'string' },
},
],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
bodyParam: { type: 'string' },
},
},
},
},
},
responses: {
'200': {
description: 'Success',
},
'400': {
description: 'Bad Request',
},
},
},
},
},
}
const mockConfig = {
baseUrl: 'http://test-api.com',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should properly propagate structured error responses', async () => {
const errorResponse = {
response: {
data: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: ['Field x is required'],
},
status: 400,
statusText: 'Bad Request',
headers: {
'content-type': 'application/json',
},
},
}
// Mock axios instance
const mockAxiosInstance = {
testOperation: vi.fn().mockRejectedValue(errorResponse),
}
// Mock the OpenAPIClientAxios initialization
const MockOpenAPIClientAxios = vi.fn().mockImplementation(() => ({
init: () => Promise.resolve(mockAxiosInstance),
}))
vi.mocked(OpenAPIClientAxios).mockImplementation(() => MockOpenAPIClientAxios())
const client = new HttpClient(mockConfig, mockOpenApiSpec)
const operation = mockOpenApiSpec.paths['/test']?.post
if (!operation) {
throw new Error('Operation not found in mock spec')
}
try {
await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, {})
// Should not reach here
expect(true).toBe(false)
} catch (error: any) {
expect(error.status).toBe(400)
expect(error.data).toEqual({
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: ['Field x is required'],
})
expect(error.message).toBe('400 Bad Request')
}
})
it('should handle query, path, and body parameters correctly', async () => {
const mockAxiosInstance = {
testOperation: vi.fn().mockResolvedValue({
data: { success: true },
status: 200,
headers: { 'content-type': 'application/json' },
}),
}
const MockOpenAPIClientAxios = vi.fn().mockImplementation(() => ({
init: () => Promise.resolve(mockAxiosInstance),
}))
vi.mocked(OpenAPIClientAxios).mockImplementation(() => MockOpenAPIClientAxios())
const client = new HttpClient(mockConfig, mockOpenApiSpec)
const operation = mockOpenApiSpec.paths['/test']?.post
if (!operation) {
throw new Error('Operation not found in mock spec')
}
const response = await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, {
queryParam: 'query1',
pathParam: 'path1',
bodyParam: 'body1',
})
expect(mockAxiosInstance.testOperation).toHaveBeenCalledWith(
{
queryParam: 'query1',
pathParam: 'path1',
},
{
bodyParam: 'body1',
},
{ headers: { 'Content-Type': 'application/json' } },
)
// Additional check to ensure headers are correctly processed
expect(response.headers.get('content-type')).toBe('application/json')
})
})
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/parser.ts:
--------------------------------------------------------------------------------
```typescript
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
import type { JSONSchema7 as IJsonSchema } from 'json-schema'
import type { ChatCompletionTool } from 'openai/resources/chat/completions'
import type { Tool } from '@anthropic-ai/sdk/resources/messages/messages'
type NewToolMethod = {
name: string
description: string
inputSchema: IJsonSchema & { type: 'object' }
returnSchema?: IJsonSchema
}
type FunctionParameters = {
type: 'object'
properties?: Record<string, unknown>
required?: string[]
[key: string]: unknown
}
export class OpenAPIToMCPConverter {
private schemaCache: Record<string, IJsonSchema> = {}
private nameCounter: number = 0
constructor(private openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {}
/**
* Resolve a $ref reference to its schema in the openApiSpec.
* Returns the raw OpenAPI SchemaObject or null if not found.
*/
private internalResolveRef(ref: string, resolvedRefs: Set<string>): OpenAPIV3.SchemaObject | null {
if (!ref.startsWith('#/')) {
return null
}
if (resolvedRefs.has(ref)) {
return null
}
const parts = ref.replace(/^#\//, '').split('/')
let current: any = this.openApiSpec
for (const part of parts) {
current = current[part]
if (!current) return null
}
resolvedRefs.add(ref)
return current as OpenAPIV3.SchemaObject
}
/**
* Convert an OpenAPI schema (or reference) into a JSON Schema object.
* Uses caching and handles cycles by returning $ref nodes.
*/
convertOpenApiSchemaToJsonSchema(
schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
resolvedRefs: Set<string>,
resolveRefs: boolean = false,
): IJsonSchema {
if ('$ref' in schema) {
const ref = schema.$ref
if (!resolveRefs) {
if (ref.startsWith('#/components/schemas/')) {
return {
$ref: ref.replace(/^#\/components\/schemas\//, '#/$defs/'),
...('description' in schema ? { description: schema.description as string } : {}),
}
}
console.error(`Attempting to resolve ref ${ref} not found in components collection.`)
// deliberate fall through
}
// Create base schema with $ref and description if present
const refSchema: IJsonSchema = { $ref: ref }
if ('description' in schema && schema.description) {
refSchema.description = schema.description as string
}
// If already cached, return immediately with description
if (this.schemaCache[ref]) {
return this.schemaCache[ref]
}
const resolved = this.internalResolveRef(ref, resolvedRefs)
if (!resolved) {
// TODO: need extensive tests for this and we definitely need to handle the case of self references
console.error(`Failed to resolve ref ${ref}`)
return {
$ref: ref.replace(/^#\/components\/schemas\//, '#/$defs/'),
description: 'description' in schema ? ((schema.description as string) ?? '') : '',
}
} else {
const converted = this.convertOpenApiSchemaToJsonSchema(resolved, resolvedRefs, resolveRefs)
this.schemaCache[ref] = converted
return converted
}
}
// Handle inline schema
const result: IJsonSchema = {}
if (schema.type) {
result.type = schema.type as IJsonSchema['type']
}
// Convert binary format to uri-reference and enhance description
if (schema.format === 'binary') {
result.format = 'uri-reference'
const binaryDesc = 'absolute paths to local files'
result.description = schema.description ? `${schema.description} (${binaryDesc})` : binaryDesc
} else {
if (schema.format) {
result.format = schema.format
}
if (schema.description) {
result.description = schema.description
}
}
if (schema.enum) {
result.enum = schema.enum
}
if (schema.default !== undefined) {
result.default = schema.default
}
// Handle object properties
if (schema.type === 'object') {
result.type = 'object'
if (schema.properties) {
result.properties = {}
for (const [name, propSchema] of Object.entries(schema.properties)) {
result.properties[name] = this.convertOpenApiSchemaToJsonSchema(propSchema, resolvedRefs, resolveRefs)
}
}
if (schema.required) {
result.required = schema.required
}
if (schema.additionalProperties === true || schema.additionalProperties === undefined) {
result.additionalProperties = true
} else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
result.additionalProperties = this.convertOpenApiSchemaToJsonSchema(schema.additionalProperties, resolvedRefs, resolveRefs)
} else {
result.additionalProperties = false
}
}
// Handle arrays - ensure binary format conversion happens for array items too
if (schema.type === 'array' && schema.items) {
result.type = 'array'
result.items = this.convertOpenApiSchemaToJsonSchema(schema.items, resolvedRefs, resolveRefs)
}
// oneOf, anyOf, allOf
if (schema.oneOf) {
result.oneOf = schema.oneOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs))
}
if (schema.anyOf) {
result.anyOf = schema.anyOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs))
}
if (schema.allOf) {
result.allOf = schema.allOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs))
}
return result
}
convertToMCPTools(): {
tools: Record<string, { methods: NewToolMethod[] }>
openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
zip: Record<string, { openApi: OpenAPIV3.OperationObject & { method: string; path: string }; mcp: NewToolMethod }>
} {
const apiName = 'API'
const openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }> = {}
const tools: Record<string, { methods: NewToolMethod[] }> = {
[apiName]: { methods: [] },
}
const zip: Record<string, { openApi: OpenAPIV3.OperationObject & { method: string; path: string }; mcp: NewToolMethod }> = {}
for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
if (!pathItem) continue
for (const [method, operation] of Object.entries(pathItem)) {
if (!this.isOperation(method, operation)) continue
const mcpMethod = this.convertOperationToMCPMethod(operation, method, path)
if (mcpMethod) {
const uniqueName = this.ensureUniqueName(mcpMethod.name)
mcpMethod.name = uniqueName
mcpMethod.description = this.getDescription(operation.summary || operation.description || '')
tools[apiName]!.methods.push(mcpMethod)
openApiLookup[apiName + '-' + uniqueName] = { ...operation, method, path }
zip[apiName + '-' + uniqueName] = { openApi: { ...operation, method, path }, mcp: mcpMethod }
}
}
}
return { tools, openApiLookup, zip }
}
/**
* Convert the OpenAPI spec to OpenAI's ChatCompletionTool format
*/
convertToOpenAITools(): ChatCompletionTool[] {
const tools: ChatCompletionTool[] = []
for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
if (!pathItem) continue
for (const [method, operation] of Object.entries(pathItem)) {
if (!this.isOperation(method, operation)) continue
const parameters = this.convertOperationToJsonSchema(operation, method, path)
const tool: ChatCompletionTool = {
type: 'function',
function: {
name: operation.operationId!,
description: this.getDescription(operation.summary || operation.description || ''),
parameters: parameters as FunctionParameters,
},
}
tools.push(tool)
}
}
return tools
}
/**
* Convert the OpenAPI spec to Anthropic's Tool format
*/
convertToAnthropicTools(): Tool[] {
const tools: Tool[] = []
for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
if (!pathItem) continue
for (const [method, operation] of Object.entries(pathItem)) {
if (!this.isOperation(method, operation)) continue
const parameters = this.convertOperationToJsonSchema(operation, method, path)
const tool: Tool = {
name: operation.operationId!,
description: this.getDescription(operation.summary || operation.description || ''),
input_schema: parameters as Tool['input_schema'],
}
tools.push(tool)
}
}
return tools
}
private convertComponentsToJsonSchema(): Record<string, IJsonSchema> {
const components = this.openApiSpec.components || {}
const schema: Record<string, IJsonSchema> = {}
for (const [key, value] of Object.entries(components.schemas || {})) {
schema[key] = this.convertOpenApiSchemaToJsonSchema(value, new Set())
}
return schema
}
/**
* Helper method to convert an operation to a JSON Schema for parameters
*/
private convertOperationToJsonSchema(
operation: OpenAPIV3.OperationObject,
method: string,
path: string,
): IJsonSchema & { type: 'object' } {
const schema: IJsonSchema & { type: 'object' } = {
type: 'object',
properties: {},
required: [],
$defs: this.convertComponentsToJsonSchema(),
}
// Handle parameters (path, query, header, cookie)
if (operation.parameters) {
for (const param of operation.parameters) {
const paramObj = this.resolveParameter(param)
if (paramObj && paramObj.schema) {
const paramSchema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set())
// Merge parameter-level description if available
if (paramObj.description) {
paramSchema.description = paramObj.description
}
schema.properties![paramObj.name] = paramSchema
if (paramObj.required) {
schema.required!.push(paramObj.name)
}
}
}
}
// Handle requestBody
if (operation.requestBody) {
const bodyObj = this.resolveRequestBody(operation.requestBody)
if (bodyObj?.content) {
if (bodyObj.content['application/json']?.schema) {
const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set())
if (bodySchema.type === 'object' && bodySchema.properties) {
for (const [name, propSchema] of Object.entries(bodySchema.properties)) {
schema.properties![name] = propSchema
}
if (bodySchema.required) {
schema.required!.push(...bodySchema.required)
}
}
}
}
}
return schema
}
private isOperation(method: string, operation: any): operation is OpenAPIV3.OperationObject {
return ['get', 'post', 'put', 'delete', 'patch'].includes(method.toLowerCase())
}
private isParameterObject(param: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject): param is OpenAPIV3.ParameterObject {
return !('$ref' in param)
}
private isRequestBodyObject(body: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject): body is OpenAPIV3.RequestBodyObject {
return !('$ref' in body)
}
private resolveParameter(param: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject): OpenAPIV3.ParameterObject | null {
if (this.isParameterObject(param)) {
return param
} else {
const resolved = this.internalResolveRef(param.$ref, new Set())
if (resolved && (resolved as OpenAPIV3.ParameterObject).name) {
return resolved as OpenAPIV3.ParameterObject
}
}
return null
}
private resolveRequestBody(body: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject): OpenAPIV3.RequestBodyObject | null {
if (this.isRequestBodyObject(body)) {
return body
} else {
const resolved = this.internalResolveRef(body.$ref, new Set())
if (resolved) {
return resolved as OpenAPIV3.RequestBodyObject
}
}
return null
}
private resolveResponse(response: OpenAPIV3.ResponseObject | OpenAPIV3.ReferenceObject): OpenAPIV3.ResponseObject | null {
if ('$ref' in response) {
const resolved = this.internalResolveRef(response.$ref, new Set())
if (resolved) {
return resolved as OpenAPIV3.ResponseObject
} else {
return null
}
}
return response
}
private convertOperationToMCPMethod(operation: OpenAPIV3.OperationObject, method: string, path: string): NewToolMethod | null {
if (!operation.operationId) {
console.warn(`Operation without operationId at ${method} ${path}`)
return null
}
const methodName = operation.operationId
const inputSchema: IJsonSchema & { type: 'object' } = {
$defs: this.convertComponentsToJsonSchema(),
type: 'object',
properties: {},
required: [],
}
// Handle parameters (path, query, header, cookie)
if (operation.parameters) {
for (const param of operation.parameters) {
const paramObj = this.resolveParameter(param)
if (paramObj && paramObj.schema) {
const schema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set(), false)
// Merge parameter-level description if available
if (paramObj.description) {
schema.description = paramObj.description
}
inputSchema.properties![paramObj.name] = schema
if (paramObj.required) {
inputSchema.required!.push(paramObj.name)
}
}
}
}
// Handle requestBody
if (operation.requestBody) {
const bodyObj = this.resolveRequestBody(operation.requestBody)
if (bodyObj?.content) {
// Handle multipart/form-data for file uploads
// We convert the multipart/form-data schema to a JSON schema and we require
// that the user passes in a string for each file that points to the local file
if (bodyObj.content['multipart/form-data']?.schema) {
const formSchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['multipart/form-data'].schema, new Set(), false)
if (formSchema.type === 'object' && formSchema.properties) {
for (const [name, propSchema] of Object.entries(formSchema.properties)) {
inputSchema.properties![name] = propSchema
}
if (formSchema.required) {
inputSchema.required!.push(...formSchema.required!)
}
}
}
// Handle application/json
else if (bodyObj.content['application/json']?.schema) {
const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set(), false)
// Merge body schema into the inputSchema's properties
if (bodySchema.type === 'object' && bodySchema.properties) {
for (const [name, propSchema] of Object.entries(bodySchema.properties)) {
inputSchema.properties![name] = propSchema
}
if (bodySchema.required) {
inputSchema.required!.push(...bodySchema.required!)
}
} else {
// If the request body is not an object, just put it under "body"
inputSchema.properties!['body'] = bodySchema
inputSchema.required!.push('body')
}
}
}
}
// Build description including error responses
let description = operation.summary || operation.description || ''
if (operation.responses) {
const errorResponses = Object.entries(operation.responses)
.filter(([code]) => code.startsWith('4') || code.startsWith('5'))
.map(([code, response]) => {
const responseObj = this.resolveResponse(response)
let errorDesc = responseObj?.description || ''
return `${code}: ${errorDesc}`
})
if (errorResponses.length > 0) {
description += '\nError Responses:\n' + errorResponses.join('\n')
}
}
// Extract return type (response schema)
const returnSchema = this.extractResponseType(operation.responses)
// Generate Zod schema from input schema
try {
// const zodSchemaStr = jsonSchemaToZod(inputSchema, { module: "cjs" })
// console.log(zodSchemaStr)
// // Execute the function with the zod instance
// const zodSchema = eval(zodSchemaStr) as z.ZodType
return {
name: methodName,
description,
inputSchema,
...(returnSchema ? { returnSchema } : {}),
}
} catch (error) {
console.warn(`Failed to generate Zod schema for ${methodName}:`, error)
// Fallback to a basic object schema
return {
name: methodName,
description,
inputSchema,
...(returnSchema ? { returnSchema } : {}),
}
}
}
private extractResponseType(responses: OpenAPIV3.ResponsesObject | undefined): IJsonSchema | null {
// Look for a success response
const successResponse = responses?.['200'] || responses?.['201'] || responses?.['202'] || responses?.['204']
if (!successResponse) return null
const responseObj = this.resolveResponse(successResponse)
if (!responseObj || !responseObj.content) return null
if (responseObj.content['application/json']?.schema) {
const returnSchema = this.convertOpenApiSchemaToJsonSchema(responseObj.content['application/json'].schema, new Set(), false)
returnSchema['$defs'] = this.convertComponentsToJsonSchema()
// Preserve the response description if available and not already set
if (responseObj.description && !returnSchema.description) {
returnSchema.description = responseObj.description
}
return returnSchema
}
// If no JSON response, fallback to a generic string or known formats
if (responseObj.content['image/png'] || responseObj.content['image/jpeg']) {
return { type: 'string', format: 'binary', description: responseObj.description || '' }
}
// Fallback
return { type: 'string', description: responseObj.description || '' }
}
private ensureUniqueName(name: string): string {
if (name.length <= 64) {
return name
}
const truncatedName = name.slice(0, 64 - 5) // Reserve space for suffix
const uniqueSuffix = this.generateUniqueSuffix()
return `${truncatedName}-${uniqueSuffix}`
}
private generateUniqueSuffix(): string {
this.nameCounter += 1
return this.nameCounter.toString().padStart(4, '0')
}
private getDescription(description: string): string {
return "Notion | " + description
}
}
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIV3 } from 'openapi-types'
import { describe, it, expect } from 'vitest'
import { OpenAPIToMCPConverter } from '../parser'
describe('OpenAPI Multipart Form Parser', () => {
it('converts single file upload endpoint to tool', () => {
const spec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/pets/{id}/photo': {
post: {
operationId: 'uploadPetPhoto',
summary: 'Upload a photo for a pet',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'integer' },
},
],
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
required: ['photo'],
properties: {
photo: {
type: 'string',
format: 'binary',
description: 'The photo to upload',
},
caption: {
type: 'string',
description: 'Optional caption for the photo',
},
},
},
},
},
},
responses: {
'201': {
description: 'Photo uploaded successfully',
},
},
},
},
},
}
const converter = new OpenAPIToMCPConverter(spec)
const { tools } = converter.convertToMCPTools()
expect(Object.keys(tools)).toHaveLength(1)
const [tool] = Object.values(tools)
expect(tool.methods).toHaveLength(1)
const [method] = tool.methods
expect(method.name).toBe('uploadPetPhoto')
expect(method.description).toContain('Upload a photo for a pet')
// Check parameters
expect(method.inputSchema.properties).toEqual({
id: {
type: 'integer',
},
photo: {
type: 'string',
format: 'uri-reference',
description: expect.stringContaining('The photo to upload (absolute paths to local files)'),
},
caption: {
type: 'string',
description: expect.stringContaining('Optional caption'),
},
})
expect(method.inputSchema.required).toContain('id')
expect(method.inputSchema.required).toContain('photo')
expect(method.inputSchema.required).not.toContain('caption')
})
it('converts multiple file upload endpoint to tool', () => {
const spec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/pets/{id}/documents': {
post: {
operationId: 'uploadPetDocuments',
summary: 'Upload multiple documents for a pet',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'integer' },
},
],
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
required: ['documents'],
properties: {
documents: {
type: 'array',
items: {
type: 'string',
format: 'binary',
},
description: 'The documents to upload (max 5 files)',
},
tags: {
type: 'array',
items: {
type: 'string',
},
description: 'Optional tags for the documents',
},
},
},
},
},
},
responses: {
'201': {
description: 'Documents uploaded successfully',
},
},
},
},
},
}
const converter = new OpenAPIToMCPConverter(spec)
const { tools } = converter.convertToMCPTools()
expect(Object.keys(tools)).toHaveLength(1)
const [tool] = Object.values(tools)
expect(tool.methods).toHaveLength(1)
const [method] = tool.methods
expect(method.name).toBe('uploadPetDocuments')
expect(method.description).toContain('Upload multiple documents')
// Check parameters
expect(method.inputSchema.properties).toEqual({
id: {
type: 'integer',
},
documents: {
type: 'array',
items: {
type: 'string',
format: 'uri-reference',
description: 'absolute paths to local files',
},
description: expect.stringContaining('max 5 files'),
},
tags: {
type: 'array',
items: {
type: 'string',
},
description: expect.stringContaining('Optional tags'),
},
})
expect(method.inputSchema.required).toContain('id')
expect(method.inputSchema.required).toContain('documents')
expect(method.inputSchema.required).not.toContain('tags')
})
it('handles complex multipart forms with mixed content', () => {
const spec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/pets/{id}/profile': {
post: {
operationId: 'updatePetProfile',
summary: 'Update pet profile with images and data',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'integer' },
},
],
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
required: ['avatar', 'details'],
properties: {
avatar: {
type: 'string',
format: 'binary',
description: 'Profile picture',
},
gallery: {
type: 'array',
items: {
type: 'string',
format: 'binary',
},
description: 'Additional pet photos',
},
details: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'integer' },
breed: { type: 'string' },
},
},
preferences: {
type: 'array',
items: {
type: 'object',
properties: {
category: { type: 'string' },
value: { type: 'string' },
},
},
},
},
},
},
},
},
responses: {
'200': {
description: 'Profile updated successfully',
},
},
},
},
},
}
const converter = new OpenAPIToMCPConverter(spec)
const { tools } = converter.convertToMCPTools()
expect(Object.keys(tools)).toHaveLength(1)
const [tool] = Object.values(tools)
expect(tool.methods).toHaveLength(1)
const [method] = tool.methods
expect(method.name).toBe('updatePetProfile')
expect(method.description).toContain('Update pet profile')
// Check parameters
expect(method.inputSchema.properties).toEqual({
id: {
type: 'integer',
},
avatar: {
type: 'string',
format: 'uri-reference',
description: expect.stringContaining('Profile picture (absolute paths to local files)'),
},
gallery: {
type: 'array',
items: {
type: 'string',
format: 'uri-reference',
description: 'absolute paths to local files',
},
description: expect.stringContaining('Additional pet photos'),
},
details: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'integer' },
breed: { type: 'string' },
},
additionalProperties: true,
},
preferences: {
type: 'array',
items: {
type: 'object',
properties: {
category: { type: 'string' },
value: { type: 'string' },
},
additionalProperties: true,
},
},
})
expect(method.inputSchema.required).toContain('id')
expect(method.inputSchema.required).toContain('avatar')
expect(method.inputSchema.required).toContain('details')
expect(method.inputSchema.required).not.toContain('gallery')
expect(method.inputSchema.required).not.toContain('preferences')
})
it('handles optional file uploads in multipart forms', () => {
const spec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/pets/{id}/metadata': {
post: {
operationId: 'updatePetMetadata',
summary: 'Update pet metadata with optional attachments',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'integer' },
},
],
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
required: ['metadata'],
properties: {
metadata: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string' },
description: { type: 'string' },
},
},
certificate: {
type: 'string',
format: 'binary',
description: 'Optional pet certificate',
},
vaccinations: {
type: 'array',
items: {
type: 'string',
format: 'binary',
},
description: 'Optional vaccination records',
},
},
},
},
},
},
responses: {
'200': {
description: 'Metadata updated successfully',
},
},
},
},
},
}
const converter = new OpenAPIToMCPConverter(spec)
const { tools } = converter.convertToMCPTools()
const [tool] = Object.values(tools)
const [method] = tool.methods
expect(method.name).toBe('updatePetMetadata')
expect(method.inputSchema.required).toContain('id')
expect(method.inputSchema.required).toContain('metadata')
expect(method.inputSchema.required).not.toContain('certificate')
expect(method.inputSchema.required).not.toContain('vaccinations')
expect(method.inputSchema.properties).toEqual({
id: {
type: 'integer',
},
metadata: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string' },
description: { type: 'string' },
},
additionalProperties: true,
},
certificate: {
type: 'string',
format: 'uri-reference',
description: expect.stringContaining('Optional pet certificate (absolute paths to local files)'),
},
vaccinations: {
type: 'array',
items: {
type: 'string',
format: 'uri-reference',
description: 'absolute paths to local files',
},
description: expect.stringContaining('Optional vaccination records'),
},
})
})
it('handles nested objects with file arrays in multipart forms', () => {
const spec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/pets/{id}/medical-records': {
post: {
operationId: 'addMedicalRecord',
summary: 'Add medical record with attachments',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'integer' },
},
],
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
required: ['record'],
properties: {
record: {
type: 'object',
required: ['date', 'type'],
properties: {
date: { type: 'string', format: 'date' },
type: { type: 'string' },
notes: { type: 'string' },
attachments: {
type: 'array',
items: {
type: 'object',
required: ['file', 'type'],
properties: {
file: {
type: 'string',
format: 'binary',
},
type: {
type: 'string',
enum: ['xray', 'lab', 'prescription'],
},
description: { type: 'string' },
},
},
},
},
},
},
},
},
},
},
responses: {
'201': {
description: 'Medical record added successfully',
},
},
},
},
},
}
const converter = new OpenAPIToMCPConverter(spec)
const { tools } = converter.convertToMCPTools()
const [tool] = Object.values(tools)
const [method] = tool.methods
expect(method.name).toBe('addMedicalRecord')
expect(method.inputSchema.required).toContain('id')
expect(method.inputSchema.required).toContain('record')
// Verify nested structure is preserved
const recordSchema = method.inputSchema.properties!.record as any
expect(recordSchema.type).toBe('object')
expect(recordSchema.required).toContain('date')
expect(recordSchema.required).toContain('type')
// Verify nested file array structure
const attachmentsSchema = recordSchema.properties.attachments
expect(attachmentsSchema.type).toBe('array')
expect(attachmentsSchema.items.type).toBe('object')
expect(attachmentsSchema.items.properties.file.format).toBe('uri-reference')
expect(attachmentsSchema.items.properties.file.description).toBe('absolute paths to local files')
expect(attachmentsSchema.items.required).toContain('file')
expect(attachmentsSchema.items.required).toContain('type')
})
it('handles oneOf/anyOf schemas with file uploads', () => {
const spec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/pets/{id}/content': {
post: {
operationId: 'addPetContent',
summary: 'Add pet content (photo or document)',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'integer' },
},
],
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
required: ['content'],
properties: {
content: {
oneOf: [
{
type: 'object',
required: ['photo', 'isProfile'],
properties: {
photo: {
type: 'string',
format: 'binary',
},
isProfile: {
type: 'boolean',
},
},
},
{
type: 'object',
required: ['document', 'category'],
properties: {
document: {
type: 'string',
format: 'binary',
},
category: {
type: 'string',
enum: ['medical', 'training', 'adoption'],
},
},
},
],
},
},
},
},
},
},
responses: {
'201': {
description: 'Content added successfully',
},
},
},
},
},
}
const converter = new OpenAPIToMCPConverter(spec)
const { tools } = converter.convertToMCPTools()
const [tool] = Object.values(tools)
const [method] = tool.methods
expect(method.name).toBe('addPetContent')
expect(method.inputSchema.required).toContain('id')
expect(method.inputSchema.required).toContain('content')
// Verify oneOf structure is preserved
const contentSchema = method.inputSchema.properties!.content as any
expect(contentSchema.oneOf).toHaveLength(2)
// Check photo option
const photoOption = contentSchema.oneOf[0]
expect(photoOption.type).toBe('object')
expect(photoOption.properties.photo.format).toBe('uri-reference')
expect(photoOption.properties.photo.description).toBe('absolute paths to local files')
expect(photoOption.required).toContain('photo')
expect(photoOption.required).toContain('isProfile')
// Check document option
const documentOption = contentSchema.oneOf[1]
expect(documentOption.type).toBe('object')
expect(documentOption.properties.document.format).toBe('uri-reference')
expect(documentOption.properties.document.description).toBe('absolute paths to local files')
expect(documentOption.required).toContain('document')
expect(documentOption.required).toContain('category')
})
})
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/__tests__/parser.test.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIToMCPConverter } from '../parser'
import { OpenAPIV3 } from 'openapi-types'
import { describe, expect, it } from 'vitest'
import { JSONSchema7 as IJsonSchema } from 'json-schema'
interface ToolMethod {
name: string
description: string
inputSchema: any
returnSchema?: any
}
interface Tool {
methods: ToolMethod[]
}
interface Tools {
[key: string]: Tool
}
// Helper function to verify tool method structure without checking the exact Zod schema
function verifyToolMethod(actual: ToolMethod, expected: any, toolName: string) {
expect(actual.name).toBe(expected.name)
expect(actual.description).toBe(expected.description)
expect(actual.inputSchema, `inputSchema ${actual.name} ${toolName}`).toEqual(expected.inputSchema)
if (expected.returnSchema) {
expect(actual.returnSchema, `returnSchema ${actual.name} ${toolName}`).toEqual(expected.returnSchema)
}
}
// Helper function to verify tools structure
function verifyTools(actual: Tools, expected: any) {
expect(Object.keys(actual)).toEqual(Object.keys(expected))
for (const [key, value] of Object.entries(actual)) {
expect(value.methods.length).toBe(expected[key].methods.length)
value.methods.forEach((method: ToolMethod, index: number) => {
verifyToolMethod(method, expected[key].methods[index], key)
})
}
}
// A helper function to derive a type from a possibly complex schema.
// If no explicit type is found, we assume 'object' for testing purposes.
function getTypeFromSchema(schema: IJsonSchema): string {
if (schema.type) {
return Array.isArray(schema.type) ? schema.type[0] : schema.type
} else if (schema.$ref) {
// If there's a $ref, we treat it as an object reference.
return 'object'
} else if (schema.oneOf || schema.anyOf || schema.allOf) {
// Complex schema combos - assume object for these tests.
return 'object'
}
return 'object'
}
// Updated helper function to get parameters from inputSchema
// Now handles $ref by treating it as an object reference without expecting properties.
function getParamsFromSchema(method: { inputSchema: IJsonSchema }) {
return Object.entries(method.inputSchema.properties || {}).map(([name, prop]) => {
if (typeof prop === 'boolean') {
throw new Error(`Boolean schema not supported for parameter ${name}`)
}
// If there's a $ref, treat it as an object reference.
const schemaType = getTypeFromSchema(prop)
return {
name,
type: schemaType,
description: prop.description,
optional: !(method.inputSchema.required || []).includes(name),
}
})
}
// Updated helper function to get return type from returnSchema
// No longer requires that the schema be fully expanded. If we have a $ref, just note it as 'object'.
function getReturnType(method: { returnSchema?: IJsonSchema }) {
if (!method.returnSchema) return null
const schema = method.returnSchema
return {
type: getTypeFromSchema(schema),
description: schema.description,
}
}
describe('OpenAPIToMCPConverter', () => {
describe('Simple API Conversion', () => {
const sampleSpec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: {
title: 'Test API',
version: '1.0.0',
},
paths: {
'/pets/{petId}': {
get: {
operationId: 'getPet',
summary: 'Get a pet by ID',
parameters: [
{
name: 'petId',
in: 'path',
required: true,
description: 'The ID of the pet',
schema: {
type: 'integer',
},
},
],
responses: {
'200': {
description: 'Pet found',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
},
},
},
},
},
},
},
},
},
}
it('converts simple OpenAPI paths to MCP tools', () => {
const converter = new OpenAPIToMCPConverter(sampleSpec)
const { tools, openApiLookup } = converter.convertToMCPTools()
expect(tools).toHaveProperty('API')
expect(tools.API.methods).toHaveLength(1)
expect(Object.keys(openApiLookup)).toHaveLength(1)
const getPetMethod = tools.API.methods.find((m) => m.name === 'getPet')
expect(getPetMethod).toBeDefined()
const params = getParamsFromSchema(getPetMethod!)
expect(params).toContainEqual({
name: 'petId',
type: 'integer',
description: 'The ID of the pet',
optional: false,
})
})
it('truncates tool names exceeding 64 characters', () => {
const longOperationId = 'a'.repeat(65)
const specWithLongName: OpenAPIV3.Document = {
openapi: '3.0.0',
info: {
title: 'Test API',
version: '1.0.0'
},
paths: {
'/pets/{petId}': {
get: {
operationId: longOperationId,
summary: 'Get a pet by ID',
parameters: [
{
name: 'petId',
in: 'path',
required: true,
description: 'The ID of the pet',
schema: {
type: 'integer'
}
}
],
responses: {
'200': {
description: 'Pet found',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' }
}
}
}
}
}
}
}
}
}
}
const converter = new OpenAPIToMCPConverter(specWithLongName)
const { tools } = converter.convertToMCPTools()
const longNameMethod = tools.API.methods.find(m => m.name.startsWith('a'.repeat(59)))
expect(longNameMethod).toBeDefined()
expect(longNameMethod!.name.length).toBeLessThanOrEqual(64)
})
})
describe('Complex API Conversion', () => {
const complexSpec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Complex API', version: '1.0.0' },
components: {
schemas: {
Error: {
type: 'object',
required: ['code', 'message'],
properties: {
code: { type: 'integer' },
message: { type: 'string' },
},
},
Pet: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'integer', description: 'The ID of the pet' },
name: { type: 'string', description: 'The name of the pet' },
category: { $ref: '#/components/schemas/Category', description: 'The category of the pet' },
tags: {
type: 'array',
description: 'The tags of the pet',
items: { $ref: '#/components/schemas/Tag' },
},
status: {
type: 'string',
description: 'The status of the pet',
enum: ['available', 'pending', 'sold'],
},
},
},
Category: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'integer' },
name: { type: 'string' },
subcategories: {
type: 'array',
items: { $ref: '#/components/schemas/Category' },
},
},
},
Tag: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'integer' },
name: { type: 'string' },
},
},
},
parameters: {
PetId: {
name: 'petId',
in: 'path',
required: true,
description: 'ID of pet to fetch',
schema: { type: 'integer' },
},
QueryLimit: {
name: 'limit',
in: 'query',
description: 'Maximum number of results to return',
schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
},
},
responses: {
NotFound: {
description: 'The specified resource was not found',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
paths: {
'/pets': {
get: {
operationId: 'listPets',
summary: 'List all pets',
parameters: [{ $ref: '#/components/parameters/QueryLimit' }],
responses: {
'200': {
description: 'A list of pets',
content: {
'application/json': {
schema: {
type: 'array',
items: { $ref: '#/components/schemas/Pet' },
},
},
},
},
},
},
post: {
operationId: 'createPet',
summary: 'Create a pet',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Pet' },
},
},
},
responses: {
'201': {
description: 'Pet created',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Pet' },
},
},
},
},
},
},
'/pets/{petId}': {
get: {
operationId: 'getPet',
summary: 'Get a pet by ID',
parameters: [{ $ref: '#/components/parameters/PetId' }],
responses: {
'200': {
description: 'Pet found',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Pet' },
},
},
},
'404': {
$ref: '#/components/responses/NotFound',
},
},
},
put: {
operationId: 'updatePet',
summary: 'Update a pet',
parameters: [{ $ref: '#/components/parameters/PetId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Pet' },
},
},
},
responses: {
'200': {
description: 'Pet updated',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Pet' },
},
},
},
'404': {
$ref: '#/components/responses/NotFound',
},
},
},
},
},
}
it('converts operations with referenced parameters', () => {
const converter = new OpenAPIToMCPConverter(complexSpec)
const { tools } = converter.convertToMCPTools()
const getPetMethod = tools.API.methods.find((m) => m.name === 'getPet')
expect(getPetMethod).toBeDefined()
const params = getParamsFromSchema(getPetMethod!)
expect(params).toContainEqual({
name: 'petId',
type: 'integer',
description: 'ID of pet to fetch',
optional: false,
})
})
it('converts operations with query parameters', () => {
const converter = new OpenAPIToMCPConverter(complexSpec)
const { tools } = converter.convertToMCPTools()
const listPetsMethod = tools.API.methods.find((m) => m.name === 'listPets')
expect(listPetsMethod).toBeDefined()
const params = getParamsFromSchema(listPetsMethod!)
expect(params).toContainEqual({
name: 'limit',
type: 'integer',
description: 'Maximum number of results to return',
optional: true,
})
})
it('converts operations with array responses', () => {
const converter = new OpenAPIToMCPConverter(complexSpec)
const { tools } = converter.convertToMCPTools()
const listPetsMethod = tools.API.methods.find((m) => m.name === 'listPets')
expect(listPetsMethod).toBeDefined()
const returnType = getReturnType(listPetsMethod!)
// Now we only check type since description might not be carried through
// if we are not expanding schemas.
expect(returnType).toMatchObject({
type: 'array',
})
})
it('converts operations with request bodies using $ref', () => {
const converter = new OpenAPIToMCPConverter(complexSpec)
const { tools } = converter.convertToMCPTools()
const createPetMethod = tools.API.methods.find((m) => m.name === 'createPet')
expect(createPetMethod).toBeDefined()
const params = getParamsFromSchema(createPetMethod!)
// Now that we are preserving $ref, the request body won't be expanded into multiple parameters.
// Instead, we'll have a single "body" parameter referencing Pet.
expect(params).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'body',
type: 'object', // Because it's a $ref
optional: false,
}),
]),
)
})
it('converts operations with referenced error responses', () => {
const converter = new OpenAPIToMCPConverter(complexSpec)
const { tools } = converter.convertToMCPTools()
const getPetMethod = tools.API.methods.find((m) => m.name === 'getPet')
expect(getPetMethod).toBeDefined()
// We just check that the description includes the error references now.
expect(getPetMethod?.description).toContain('404: The specified resource was not found')
})
it('handles recursive schema references without expanding them', () => {
const converter = new OpenAPIToMCPConverter(complexSpec)
const { tools } = converter.convertToMCPTools()
const createPetMethod = tools.API.methods.find((m) => m.name === 'createPet')
expect(createPetMethod).toBeDefined()
const params = getParamsFromSchema(createPetMethod!)
// Since "category" would be inside Pet, and we're not expanding,
// we won't see 'category' directly. We only have 'body' as a reference.
// Thus, the test no longer checks for a direct 'category' param.
expect(params.find((p) => p.name === 'body')).toBeDefined()
})
it('converts all operations correctly respecting $ref usage', () => {
const converter = new OpenAPIToMCPConverter(complexSpec)
const { tools } = converter.convertToMCPTools()
expect(tools.API.methods).toHaveLength(4)
const methodNames = tools.API.methods.map((m) => m.name)
expect(methodNames).toEqual(expect.arrayContaining(['listPets', 'createPet', 'getPet', 'updatePet']))
tools.API.methods.forEach((method) => {
expect(method).toHaveProperty('name')
expect(method).toHaveProperty('description')
expect(method).toHaveProperty('inputSchema')
expect(method).toHaveProperty('returnSchema')
// For 'get' operations, we just check the return type is recognized correctly.
if (method.name.startsWith('get')) {
const returnType = getReturnType(method)
// With $ref usage, we can't guarantee description or direct expansion.
expect(returnType?.type).toBe('object')
}
})
})
})
describe('Complex Schema Conversion', () => {
// A similar approach for the nested spec
// Just as in the previous tests, we no longer test for direct property expansion.
// We only confirm that parameters and return types are recognized and that references are preserved.
const nestedSpec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Nested API', version: '1.0.0' },
components: {
schemas: {
Organization: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'integer' },
name: { type: 'string' },
departments: {
type: 'array',
items: { $ref: '#/components/schemas/Department' },
},
metadata: { $ref: '#/components/schemas/Metadata' },
},
},
Department: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'integer' },
name: { type: 'string' },
employees: {
type: 'array',
items: { $ref: '#/components/schemas/Employee' },
},
subDepartments: {
type: 'array',
items: { $ref: '#/components/schemas/Department' },
},
metadata: { $ref: '#/components/schemas/Metadata' },
},
},
Employee: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'integer' },
name: { type: 'string' },
role: { $ref: '#/components/schemas/Role' },
skills: {
type: 'array',
items: { $ref: '#/components/schemas/Skill' },
},
metadata: { $ref: '#/components/schemas/Metadata' },
},
},
Role: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'integer' },
name: { type: 'string' },
permissions: {
type: 'array',
items: { $ref: '#/components/schemas/Permission' },
},
},
},
Permission: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'integer' },
name: { type: 'string' },
scope: { type: 'string' },
},
},
Skill: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'integer' },
name: { type: 'string' },
level: {
type: 'string',
enum: ['beginner', 'intermediate', 'expert'],
},
},
},
Metadata: {
type: 'object',
properties: {
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
tags: {
type: 'array',
items: { type: 'string' },
},
customFields: {
type: 'object',
additionalProperties: true,
},
},
},
},
parameters: {
OrgId: {
name: 'orgId',
in: 'path',
required: true,
description: 'Organization ID',
schema: { type: 'integer' },
},
DeptId: {
name: 'deptId',
in: 'path',
required: true,
description: 'Department ID',
schema: { type: 'integer' },
},
IncludeMetadata: {
name: 'includeMetadata',
in: 'query',
description: 'Include metadata in response',
schema: { type: 'boolean', default: false },
},
Depth: {
name: 'depth',
in: 'query',
description: 'Depth of nested objects to return',
schema: { type: 'integer', minimum: 1, maximum: 5, default: 1 },
},
},
},
paths: {
'/organizations/{orgId}': {
get: {
operationId: 'getOrganization',
summary: 'Get organization details',
parameters: [
{ $ref: '#/components/parameters/OrgId' },
{ $ref: '#/components/parameters/IncludeMetadata' },
{ $ref: '#/components/parameters/Depth' },
],
responses: {
'200': {
description: 'Organization details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Organization' },
},
},
},
},
},
},
'/organizations/{orgId}/departments/{deptId}': {
get: {
operationId: 'getDepartment',
summary: 'Get department details',
parameters: [
{ $ref: '#/components/parameters/OrgId' },
{ $ref: '#/components/parameters/DeptId' },
{ $ref: '#/components/parameters/IncludeMetadata' },
{ $ref: '#/components/parameters/Depth' },
],
responses: {
'200': {
description: 'Department details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Department' },
},
},
},
},
},
put: {
operationId: 'updateDepartment',
summary: 'Update department details',
parameters: [{ $ref: '#/components/parameters/OrgId' }, { $ref: '#/components/parameters/DeptId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Department' },
},
},
},
responses: {
'200': {
description: 'Department updated',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Department' },
},
},
},
},
},
},
},
}
it('handles deeply nested object references', () => {
const converter = new OpenAPIToMCPConverter(nestedSpec)
const { tools } = converter.convertToMCPTools()
const getOrgMethod = tools.API.methods.find((m) => m.name === 'getOrganization')
expect(getOrgMethod).toBeDefined()
const params = getParamsFromSchema(getOrgMethod!)
expect(params).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'orgId',
type: 'integer',
description: 'Organization ID',
optional: false,
}),
expect.objectContaining({
name: 'includeMetadata',
type: 'boolean',
description: 'Include metadata in response',
optional: true,
}),
expect.objectContaining({
name: 'depth',
type: 'integer',
description: 'Depth of nested objects to return',
optional: true,
}),
]),
)
})
it('handles recursive array references without requiring expansion', () => {
const converter = new OpenAPIToMCPConverter(nestedSpec)
const { tools } = converter.convertToMCPTools()
const updateDeptMethod = tools.API.methods.find((m) => m.name === 'updateDepartment')
expect(updateDeptMethod).toBeDefined()
const params = getParamsFromSchema(updateDeptMethod!)
// With $ref usage, we have a body parameter referencing Department.
// The subDepartments array is inside Department, so we won't see it expanded here.
// Instead, we just confirm 'body' is present.
const bodyParam = params.find((p) => p.name === 'body')
expect(bodyParam).toBeDefined()
expect(bodyParam?.type).toBe('object')
})
it('handles complex nested object hierarchies without expansion', () => {
const converter = new OpenAPIToMCPConverter(nestedSpec)
const { tools } = converter.convertToMCPTools()
const getDeptMethod = tools.API.methods.find((m) => m.name === 'getDepartment')
expect(getDeptMethod).toBeDefined()
const params = getParamsFromSchema(getDeptMethod!)
// Just checking top-level params:
expect(params).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'orgId',
type: 'integer',
optional: false,
}),
expect.objectContaining({
name: 'deptId',
type: 'integer',
optional: false,
}),
expect.objectContaining({
name: 'includeMetadata',
type: 'boolean',
optional: true,
}),
expect.objectContaining({
name: 'depth',
type: 'integer',
optional: true,
}),
]),
)
})
it('handles schema with mixed primitive and reference types without expansion', () => {
const converter = new OpenAPIToMCPConverter(nestedSpec)
const { tools } = converter.convertToMCPTools()
const updateDeptMethod = tools.API.methods.find((m) => m.name === 'updateDepartment')
expect(updateDeptMethod).toBeDefined()
const params = getParamsFromSchema(updateDeptMethod!)
// Since we are not expanding, we won't see metadata fields directly.
// We just confirm 'body' referencing Department is there.
expect(params.find((p) => p.name === 'body')).toBeDefined()
})
it('converts all operations with complex schemas correctly respecting $ref', () => {
const converter = new OpenAPIToMCPConverter(nestedSpec)
const { tools } = converter.convertToMCPTools()
expect(tools.API.methods).toHaveLength(3)
const methodNames = tools.API.methods.map((m) => m.name)
expect(methodNames).toEqual(expect.arrayContaining(['getOrganization', 'getDepartment', 'updateDepartment']))
tools.API.methods.forEach((method) => {
expect(method).toHaveProperty('name')
expect(method).toHaveProperty('description')
expect(method).toHaveProperty('inputSchema')
expect(method).toHaveProperty('returnSchema')
// If it's a GET operation, check that return type is recognized.
if (method.name.startsWith('get')) {
const returnType = getReturnType(method)
// Without expansion, just check type is recognized as object.
expect(returnType).toMatchObject({
type: 'object',
})
}
})
})
})
it('preserves description on $ref nodes', () => {
const spec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
schemas: {
TestSchema: {
type: 'object',
properties: {
name: { type: 'string' },
},
},
},
},
}
const converter = new OpenAPIToMCPConverter(spec)
const result = converter.convertOpenApiSchemaToJsonSchema(
{
$ref: '#/components/schemas/TestSchema',
description: 'A schema description',
},
new Set(),
)
expect(result).toEqual({
$ref: '#/$defs/TestSchema',
description: 'A schema description',
})
})
})
// Additional complex test scenarios as a table test
describe('OpenAPIToMCPConverter - Additional Complex Tests', () => {
interface TestCase {
name: string
input: OpenAPIV3.Document
expected: {
tools: Record<
string,
{
methods: Array<{
name: string
description: string
inputSchema: IJsonSchema & { type: 'object' }
returnSchema?: IJsonSchema
}>
}
>
openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
}
}
const cases: TestCase[] = [
{
name: 'Cyclic References with Full Descriptions',
input: {
openapi: '3.0.0',
info: {
title: 'Cyclic Test API',
version: '1.0.0',
},
paths: {
'/ab': {
get: {
operationId: 'getAB',
summary: 'Get an A-B object',
responses: {
'200': {
description: 'Returns an A object',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/A' },
},
},
},
},
},
post: {
operationId: 'createAB',
summary: 'Create an A-B object',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/A',
description: 'A schema description',
},
},
},
},
responses: {
'201': {
description: 'Created A object',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/A' },
},
},
},
},
},
},
},
components: {
schemas: {
A: {
type: 'object',
description: 'A schema description',
required: ['name', 'b'],
properties: {
name: {
type: 'string',
description: 'Name of A',
},
b: {
$ref: '#/components/schemas/B',
description: 'B property in A',
},
},
},
B: {
type: 'object',
description: 'B schema description',
required: ['title', 'a'],
properties: {
title: {
type: 'string',
description: 'Title of B',
},
a: {
$ref: '#/components/schemas/A',
description: 'A property in B',
},
},
},
},
},
} as OpenAPIV3.Document,
expected: {
tools: {
API: {
methods: [
{
name: 'getAB',
description: 'Get an A-B object',
// Error responses might not be listed here since none are defined.
// Just end the description with no Error Responses section.
inputSchema: {
type: 'object',
properties: {},
required: [],
$defs: {
A: {
type: 'object',
description: 'A schema description',
additionalProperties: true,
properties: {
name: {
type: 'string',
description: 'Name of A',
},
b: {
description: 'B property in A',
$ref: '#/$defs/B',
},
},
required: ['name', 'b'],
},
B: {
type: 'object',
description: 'B schema description',
additionalProperties: true,
properties: {
title: {
type: 'string',
description: 'Title of B',
},
a: {
description: 'A property in B',
$ref: '#/$defs/A',
},
},
required: ['title', 'a'],
},
},
},
returnSchema: {
$ref: '#/$defs/A',
description: 'Returns an A object',
$defs: {
A: {
type: 'object',
description: 'A schema description',
additionalProperties: true,
properties: {
name: {
type: 'string',
description: 'Name of A',
},
b: {
description: 'B property in A',
$ref: '#/$defs/B',
},
},
required: ['name', 'b'],
},
B: {
type: 'object',
description: 'B schema description',
additionalProperties: true,
properties: {
title: {
type: 'string',
description: 'Title of B',
},
a: {
description: 'A property in B',
$ref: '#/$defs/A',
},
},
required: ['title', 'a'],
},
},
},
},
{
name: 'createAB',
description: 'Create an A-B object',
inputSchema: {
type: 'object',
properties: {
// The requestBody references A. We keep it as a single body field with a $ref.
body: {
$ref: '#/$defs/A',
description: 'A schema description',
},
},
required: ['body'],
$defs: {
A: {
type: 'object',
description: 'A schema description',
additionalProperties: true,
properties: {
name: {
type: 'string',
description: 'Name of A',
},
b: {
description: 'B property in A',
$ref: '#/$defs/B',
},
},
required: ['name', 'b'],
},
B: {
type: 'object',
description: 'B schema description',
additionalProperties: true,
properties: {
title: {
type: 'string',
description: 'Title of B',
},
a: {
description: 'A property in B',
$ref: '#/$defs/A',
},
},
required: ['title', 'a'],
},
},
},
returnSchema: {
$ref: '#/$defs/A',
description: 'Created A object',
$defs: {
A: {
type: 'object',
description: 'A schema description',
additionalProperties: true,
properties: {
name: {
type: 'string',
description: 'Name of A',
},
b: {
description: 'B property in A',
$ref: '#/$defs/B',
},
},
required: ['name', 'b'],
},
B: {
type: 'object',
description: 'B schema description',
additionalProperties: true,
properties: {
title: {
type: 'string',
description: 'Title of B',
},
a: {
description: 'A property in B',
$ref: '#/$defs/A',
},
},
required: ['title', 'a'],
},
},
},
},
],
},
},
openApiLookup: {
'API-getAB': {
operationId: 'getAB',
summary: 'Get an A-B object',
responses: {
'200': {
description: 'Returns an A object',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/A' },
},
},
},
},
method: 'get',
path: '/ab',
},
'API-createAB': {
operationId: 'createAB',
summary: 'Create an A-B object',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/A',
description: 'A schema description',
},
},
},
},
responses: {
'201': {
description: 'Created A object',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/A' },
},
},
},
},
method: 'post',
path: '/ab',
},
},
},
},
{
name: 'allOf/oneOf References with Full Descriptions',
input: {
openapi: '3.0.0',
info: { title: 'Composed Schema API', version: '1.0.0' },
paths: {
'/composed': {
get: {
operationId: 'getComposed',
summary: 'Get a composed resource',
responses: {
'200': {
description: 'A composed object',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/C' },
},
},
},
},
},
},
},
components: {
schemas: {
Base: {
type: 'object',
description: 'Base schema description',
properties: {
baseName: {
type: 'string',
description: 'Name in the base schema',
},
},
},
D: {
type: 'object',
description: 'D schema description',
properties: {
dProp: {
type: 'integer',
description: 'D property integer',
},
},
},
E: {
type: 'object',
description: 'E schema description',
properties: {
choice: {
description: 'One of these choices',
oneOf: [
{
$ref: '#/components/schemas/F',
},
{
$ref: '#/components/schemas/G',
},
],
},
},
},
F: {
type: 'object',
description: 'F schema description',
properties: {
fVal: {
type: 'boolean',
description: 'Boolean in F',
},
},
},
G: {
type: 'object',
description: 'G schema description',
properties: {
gVal: {
type: 'string',
description: 'String in G',
},
},
},
C: {
description: 'C schema description',
allOf: [{ $ref: '#/components/schemas/Base' }, { $ref: '#/components/schemas/D' }, { $ref: '#/components/schemas/E' }],
},
},
},
} as OpenAPIV3.Document,
expected: {
tools: {
API: {
methods: [
{
name: 'getComposed',
description: 'Get a composed resource',
inputSchema: {
type: 'object',
properties: {},
required: [],
$defs: {
Base: {
type: 'object',
description: 'Base schema description',
additionalProperties: true,
properties: {
baseName: {
type: 'string',
description: 'Name in the base schema',
},
},
},
C: {
description: 'C schema description',
allOf: [{ $ref: '#/$defs/Base' }, { $ref: '#/$defs/D' }, { $ref: '#/$defs/E' }],
},
D: {
type: 'object',
additionalProperties: true,
description: 'D schema description',
properties: {
dProp: {
type: 'integer',
description: 'D property integer',
},
},
},
E: {
type: 'object',
additionalProperties: true,
description: 'E schema description',
properties: {
choice: {
description: 'One of these choices',
oneOf: [{ $ref: '#/$defs/F' }, { $ref: '#/$defs/G' }],
},
},
},
F: {
type: 'object',
additionalProperties: true,
description: 'F schema description',
properties: {
fVal: {
type: 'boolean',
description: 'Boolean in F',
},
},
},
G: {
type: 'object',
additionalProperties: true,
description: 'G schema description',
properties: {
gVal: {
type: 'string',
description: 'String in G',
},
},
},
},
},
returnSchema: {
$ref: '#/$defs/C',
description: 'A composed object',
$defs: {
Base: {
type: 'object',
description: 'Base schema description',
additionalProperties: true,
properties: {
baseName: {
type: 'string',
description: 'Name in the base schema',
},
},
},
C: {
description: 'C schema description',
allOf: [{ $ref: '#/$defs/Base' }, { $ref: '#/$defs/D' }, { $ref: '#/$defs/E' }],
},
D: {
type: 'object',
additionalProperties: true,
description: 'D schema description',
properties: {
dProp: {
type: 'integer',
description: 'D property integer',
},
},
},
E: {
type: 'object',
additionalProperties: true,
description: 'E schema description',
properties: {
choice: {
description: 'One of these choices',
oneOf: [{ $ref: '#/$defs/F' }, { $ref: '#/$defs/G' }],
},
},
},
F: {
type: 'object',
additionalProperties: true,
description: 'F schema description',
properties: {
fVal: {
type: 'boolean',
description: 'Boolean in F',
},
},
},
G: {
type: 'object',
additionalProperties: true,
description: 'G schema description',
properties: {
gVal: {
type: 'string',
description: 'String in G',
},
},
},
},
},
},
],
},
},
openApiLookup: {
'API-getComposed': {
operationId: 'getComposed',
summary: 'Get a composed resource',
responses: {
'200': {
description: 'A composed object',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/C' },
},
},
},
},
method: 'get',
path: '/composed',
},
},
},
},
]
it.each(cases)('$name', ({ input, expected }) => {
const converter = new OpenAPIToMCPConverter(input)
const { tools, openApiLookup } = converter.convertToMCPTools()
// Use the custom verification instead of direct equality
verifyTools(tools, expected.tools)
expect(openApiLookup).toEqual(expected.openApiLookup)
})
})
```