# Directory Structure
```
├── .dockerignore
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── docs
│ └── images
│ ├── connections.png
│ ├── integrations-capabilities.png
│ ├── integrations-creation.png
│ └── notion-api-tools-comparison.png
├── examples
│ └── petstore-server.cjs
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── scripts
│ ├── build-cli.js
│ ├── notion-openapi.json
│ └── start-server.ts
├── src
│ ├── init-server.ts
│ └── openapi-mcp-server
│ ├── auth
│ │ ├── index.ts
│ │ ├── template.ts
│ │ └── types.ts
│ ├── client
│ │ ├── __tests__
│ │ │ ├── http-client-upload.test.ts
│ │ │ ├── http-client.integration.test.ts
│ │ │ └── http-client.test.ts
│ │ └── http-client.ts
│ ├── index.ts
│ ├── mcp
│ │ ├── __tests__
│ │ │ ├── one-pager.test.ts
│ │ │ └── proxy.test.ts
│ │ └── proxy.ts
│ ├── openapi
│ │ ├── __tests__
│ │ │ ├── file-upload.test.ts
│ │ │ ├── parser-multipart.test.ts
│ │ │ └── parser.test.ts
│ │ ├── file-upload.ts
│ │ └── parser.ts
│ └── README.md
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
node_modules
Dockerfile
docker-compose.yml
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
node_modules/
build/
dist
bin/
.cache
.yarn/cache
.eslintcache
.cursor
.DS_Store
.env
```
--------------------------------------------------------------------------------
/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 ReadOnly MCP Server
This project implements an optimized read-only MCP server for the Notion API, focusing on performance and efficiency for AI assistants to query and retrieve Notion content.
<a href="https://glama.ai/mcp/servers/@Taewoong1378/notion-readonly-mcp-server">
<img width="380" height="200" src="https://glama.ai/mcp/servers/@Taewoong1378/notion-readonly-mcp-server/badge" alt="Notion ReadOnly Server MCP server" />
</a>
## Key Improvements
- **Read-Only Design**: Focused exclusively on data retrieval operations, ensuring safe access to Notion content.
- **Minimized Tool Set**: Reduced the number of exposed Notion API tools from 15+ to only 6 essential ones for document analysis.
- **Parallel Processing**: Enhanced performance by implementing asynchronous and parallel API requests for retrieving block content, significantly reducing response times.
- **Extended Database Access**: Added support for database, page property, and comment retrieval operations.
- **Optimized for AI Assistants**: Significantly reduced tool count addresses the "Too many tools can degrade performance" issue in AI assistants like Cursor, which limits models to approximately 40 tools.
## Tool Comparison
This read-only implementation exposes far fewer tools compared to the standard Notion API integration, improving performance and compatibility with AI assistants:

The reduced tool set helps stay within the recommended tool limits for optimal AI assistant performance while still providing all essential functionality.
## Installation
### 1. Setting up Integration in Notion:
Go to 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 to read-only operations, there is a non-zero risk to workspace data by exposing it to LLMs. Security-conscious users may want to further configure the Integration's _Capabilities_.
For example, you can create a read-only integration token by giving only "Read content" access from the "Configuration" tab:

### 2. Adding MCP config to your client:
#### Using npm:
Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json` (MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json`)
```json
{
"mcpServers": {
"notionApi": {
"command": "npx",
"args": ["-y", "notion-readonly-mcp-server"],
"env": {
"OPENAPI_MCP_HEADERS": "{\"Authorization\": \"Bearer ntn_****\", \"Notion-Version\": \"2022-06-28\" }"
}
}
}
}
```
#### Using Docker:
Add the following to your `.cursor/mcp.json` or `claude_desktop_config.json`:
```json
{
"mcpServers": {
"notionApi": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-e", "OPENAPI_MCP_HEADERS",
"taewoong1378/notion-readonly-mcp-server"
],
"env": {
"OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer ntn_****\",\"Notion-Version\":\"2022-06-28\"}"
}
}
}
}
```
Don't forget to replace `ntn_****` with your integration secret. Find it from your integration configuration tab.
### 3. Connecting content to integration:
Ensure relevant pages and databases are connected to your integration.
To do this, visit the page, click on the 3 dots, and select "Connect to integration".

## Available Tools
This optimized server exposes only essential read-only Notion API tools:
- `API-retrieve-a-page`: Get page information
- `API-get-block-children`: Get page content blocks (with parallel processing)
- `API-retrieve-a-block`: Get details about a specific block
- `API-retrieve-a-database`: Get database information
- `API-retrieve-a-comment`: Get comments on a page or block
- `API-retrieve-a-page-property`: Get specific property information from a page
- `API-get-one-pager`: **NEW!** Recursively retrieve a full Notion page with all its blocks, databases, and related content in a single call
By limiting to these 7 essential tools (compared to 15+ in the standard implementation), we ensure:
1. Better performance in AI assistants like Cursor and Claude that have tool count limitations
2. Reduced cognitive load for AI models when choosing appropriate tools
3. Faster response times with fewer API options to consider
4. Enhanced security through minimized API surface area
## Automatic Content Exploration
The new `API-get-one-pager` tool provides a powerful way to explore Notion pages without requiring multiple API calls:
- **Recursive retrieval**: Automatically traverses the entire page structure including nested blocks
- **Parallel processing**: Fetches multiple blocks and their children simultaneously for maximum performance
- **Intelligent caching**: Stores retrieved data to minimize redundant API calls
- **Comprehensive content**: Includes pages, blocks, databases, comments, and detailed property information
- **Customizable depth**: Control the level of recursion to balance between detail and performance
### Using One Pager Tool
```
{
"page_id": "YOUR_PAGE_ID",
"maxDepth": 5, // Optional: Maximum recursion depth (default: 5)
"includeDatabases": true, // Optional: Include linked databases (default: true)
"includeComments": true, // Optional: Include comments (default: true)
"includeProperties": true // Optional: Include detailed page properties (default: true)
}
```
This automatic exploration capability is especially useful for AI assistants that need to understand the entire content of a Notion page without making dozens of separate API calls, resulting in much faster and more efficient responses.
## Asynchronous Processing
The server implements advanced parallel processing techniques for handling large Notion documents:
- Multiple requests are batched and processed concurrently
- Pagination is handled automatically for block children
- Results are efficiently aggregated before being returned
- Console logging provides visibility into the process without affecting response format
## Examples
1. Using the following instruction:
```
Get the content of page 1a6b35e6e67f802fa7e1d27686f017f2
```
The AI will retrieve the page details efficiently with parallel processing of block content.
2. Using database information:
```
Get the structure of database 8a6b35e6e67f802fa7e1d27686f017f2
```
## Development
Build:
```
pnpm build
```
Execute:
```
pnpm dev
```
## License
MIT
## AI Assistant Performance Benefits
Modern AI assistants like Cursor and Claude have limitations on the number of tools they can effectively handle:
- Most models may not respect more than 40 tools in total
- Too many tools can degrade overall performance and reasoning capabilities
- Complex tool sets increase response latency and decision-making difficulty
This read-only implementation deliberately reduces the Notion API surface to address these limitations while preserving all essential functionality. The result is:
- Faster and more reliable responses from AI assistants
- Improved accuracy when interacting with Notion content
- Better overall performance through focused API design
```
--------------------------------------------------------------------------------
/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", "examples/**/*.cjs"]
}
```
--------------------------------------------------------------------------------
/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);
});
```
--------------------------------------------------------------------------------
/scripts/start-server.ts:
--------------------------------------------------------------------------------
```typescript
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import path from 'node:path'
import { fileURLToPath } from 'url'
import { initProxy, ValidationError } from '../src/init-server'
export async function startServer(args: string[] = process.argv.slice(2)) {
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
const proxy = await initProxy(specPath, baseUrl)
await proxy.connect(new StdioServerTransport())
return proxy.getServer()
}
startServer().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)
})
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# 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/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": "notion-readonly-mcp-server",
"keywords": [
"notion",
"api",
"mcp",
"server",
"read-only",
"async"
],
"version": "1.0.9",
"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.8.0",
"axios": "^1.8.4",
"form-data": "^4.0.1",
"mustache": "^4.2.0",
"openapi-client-axios": "^7.5.5",
"openapi-schema-validator": "^12.1.3",
"openapi-types": "^12.1.3",
"which": "^5.0.0",
"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",
"body-parser": "^2.2.0",
"esbuild": "^0.25.2",
"express": "^4.21.2",
"multer": "1.4.5-lts.1",
"openai": "^4.91.1",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^3.1.1"
},
"description": "Optimized read-only MCP server for Notion API with asynchronous processing",
"main": "index.js",
"repository": {
"type": "git",
"url": "[email protected]:Taewoong1378/notion-readonly-mcp-server.git"
},
"author": "@taewoong1378",
"bugs": {
"url": "https://github.com/Taewoong1378/notion-readonly-mcp-server/issues"
},
"homepage": "https://github.com/Taewoong1378/notion-readonly-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-upload.test.ts:
--------------------------------------------------------------------------------
```typescript
import fs from 'fs'
import { OpenAPIV3 } from 'openapi-types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { HttpClient } from '../http-client'
// 모킹 방식 변경
vi.mock('fs', () => {
return {
default: {
createReadStream: vi.fn()
},
createReadStream: vi.fn()
}
})
vi.mock('form-data', () => {
const FormDataMock = vi.fn().mockImplementation(() => ({
append: vi.fn(),
getHeaders: vi.fn().mockReturnValue({ 'content-type': 'multipart/form-data; boundary=---123' })
}))
return {
default: FormDataMock
}
})
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 mockFileStream = { pipe: vi.fn() }
// 모킹 방식 변경
vi.mocked(fs.createReadStream).mockReturnValue(mockFileStream as any)
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(mockApiInstance.uploadFile).toHaveBeenCalled()
})
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 mockFileStream1 = { pipe: vi.fn() }
const mockFileStream2 = { pipe: vi.fn() }
// createReadStream 모킹을 시퀀스로 설정
vi.mocked(fs.createReadStream)
.mockReturnValueOnce(mockFileStream1 as any)
.mockReturnValueOnce(mockFileStream2 as any)
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(mockApiInstance.uploadFile).toHaveBeenCalled()
})
})
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts:
--------------------------------------------------------------------------------
```typescript
import type express from 'express'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { HttpClient } from '../http-client'
//@ts-ignore
import axios from 'axios'
import type { OpenAPIV3 } from 'openapi-types'
import { createPetstoreServer } from '../../../../examples/petstore-server.cjs'
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 { 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/__tests__/proxy.test.ts:
--------------------------------------------------------------------------------
```typescript
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { OpenAPIV3 } from 'openapi-types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HttpClient } from '../../client/http-client'
import { MCPProxy } from '../proxy'
// 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: {},
},
}),
).resolves.toEqual({
content: [
{
type: 'text',
text: JSON.stringify({
status: 'error',
message: 'Method nonExistentMethod not found.',
code: 404
}),
},
],
})
})
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')
})
})
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/mcp/__tests__/one-pager.test.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIV3 } from 'openapi-types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { HttpClient } from '../../client/http-client'
import { MCPProxy } from '../proxy'
// Mock the dependencies
vi.mock('../../client/http-client')
vi.mock('@modelcontextprotocol/sdk/server/index.js')
describe('MCPProxy - One Pager Functionality', () => {
let proxy: MCPProxy
let mockOpenApiSpec: OpenAPIV3.Document
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
// Setup OpenAPI spec for testing
mockOpenApiSpec = {
openapi: '3.0.0',
servers: [{ url: 'http://localhost:3000' }],
info: {
title: 'Notion API',
version: '1.0.0',
},
paths: {
'/v1/pages/{page_id}': {
get: {
operationId: 'retrieve-a-page',
parameters: [
{
name: 'page_id',
in: 'path',
required: true,
schema: { type: 'string' }
}
],
responses: {
'200': {
description: 'Success',
},
},
},
},
'/v1/blocks/{block_id}/children': {
get: {
operationId: 'get-block-children',
parameters: [
{
name: 'block_id',
in: 'path',
required: true,
schema: { type: 'string' }
},
{
name: 'page_size',
in: 'query',
schema: { type: 'integer' }
}
],
responses: {
'200': {
description: 'Success',
},
},
},
},
'/v1/blocks/{block_id}': {
get: {
operationId: 'retrieve-a-block',
parameters: [
{
name: 'block_id',
in: 'path',
required: true,
schema: { type: 'string' }
}
],
responses: {
'200': {
description: 'Success',
},
},
},
},
'/v1/databases/{database_id}': {
get: {
operationId: 'retrieve-a-database',
parameters: [
{
name: 'database_id',
in: 'path',
required: true,
schema: { type: 'string' }
}
],
responses: {
'200': {
description: 'Success',
},
},
},
},
'/v1/comments': {
get: {
operationId: 'retrieve-a-comment',
parameters: [
{
name: 'block_id',
in: 'query',
required: true,
schema: { type: 'string' }
}
],
responses: {
'200': {
description: 'Success',
},
},
},
},
'/v1/pages/{page_id}/properties/{property_id}': {
get: {
operationId: 'retrieve-a-page-property',
parameters: [
{
name: 'page_id',
in: 'path',
required: true,
schema: { type: 'string' }
},
{
name: 'property_id',
in: 'path',
required: true,
schema: { type: 'string' }
}
],
responses: {
'200': {
description: 'Success',
},
},
},
},
},
}
proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
})
describe('handleOnePagerRequest', () => {
it('should recursively retrieve page content', async () => {
// Set up mocks for each API response
// 1. Mock page response
const mockPageResponse = {
data: {
object: 'page',
id: 'test-page-id',
properties: {
title: {
id: 'title',
type: 'title',
title: [{ type: 'text', text: { content: 'Test Page' } }]
}
},
has_children: true
},
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
}
// 2. Mock block children response
const mockBlocksResponse = {
data: {
object: 'list',
results: [
{
object: 'block',
id: 'block-1',
type: 'paragraph',
has_children: false,
paragraph: {
rich_text: [{ type: 'text', text: { content: 'Test paragraph' } }]
}
},
{
object: 'block',
id: 'block-2',
type: 'child_database',
has_children: false,
child_database: {
database_id: 'db-1'
}
}
],
next_cursor: null,
has_more: false
},
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
}
// 3. Mock database response
const mockDatabaseResponse = {
data: {
object: 'database',
id: 'db-1',
title: [{ type: 'text', text: { content: 'Test Database' } }],
properties: {
Name: {
id: 'title',
type: 'title',
title: {}
}
}
},
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
}
// 4. Mock comments response
const mockCommentsResponse = {
data: {
object: 'list',
results: [
{
object: 'comment',
id: 'comment-1',
rich_text: [{ type: 'text', text: { content: 'Test comment' } }]
}
],
has_more: false
},
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
}
// Set up the mock API responses
const executeOperationMock = HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>
executeOperationMock.mockImplementation((operation, params) => {
if (operation.operationId === 'retrieve-a-page') {
return Promise.resolve(mockPageResponse)
} else if (operation.operationId === 'get-block-children') {
return Promise.resolve(mockBlocksResponse)
} else if (operation.operationId === 'retrieve-a-database') {
return Promise.resolve(mockDatabaseResponse)
} else if (operation.operationId === 'retrieve-a-comment') {
return Promise.resolve(mockCommentsResponse)
}
return Promise.resolve({ data: {}, status: 200, headers: new Headers() })
})
// Set up openApiLookup with our test operations
const openApiLookup = {
'API-retrieve-a-page': {
operationId: 'retrieve-a-page',
method: 'get',
path: '/v1/pages/{page_id}',
},
'API-get-block-children': {
operationId: 'get-block-children',
method: 'get',
path: '/v1/blocks/{block_id}/children',
},
'API-retrieve-a-database': {
operationId: 'retrieve-a-database',
method: 'get',
path: '/v1/databases/{database_id}',
},
'API-retrieve-a-comment': {
operationId: 'retrieve-a-comment',
method: 'get',
path: '/v1/comments',
},
}
;(proxy as any).openApiLookup = openApiLookup
// Get the server request handlers
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]
// Call the get-one-pager tool
const result = await callToolHandler({
params: {
name: 'API-get-one-pager',
arguments: {
page_id: 'test-page-id',
maxDepth: 2,
includeDatabases: true,
includeComments: true
},
},
})
// Parse the result
const onePagerData = JSON.parse(result.content[0].text)
// Verify the structure of the One Pager result
expect(onePagerData).toHaveProperty('id', 'test-page-id')
expect(onePagerData).toHaveProperty('content')
// Verify that recursive content was retrieved
expect(onePagerData.content).toBeInstanceOf(Array)
expect(onePagerData.content.length).toBeGreaterThan(0)
// Verify that at least one comment was retrieved
expect(onePagerData).toHaveProperty('comments')
expect(onePagerData.comments.results.length).toBeGreaterThan(0)
// Verify database information was retrieved
const databaseBlock = onePagerData.content.find((block: any) => block.type === 'child_database')
expect(databaseBlock).toBeDefined()
expect(databaseBlock).toHaveProperty('database')
expect(databaseBlock.database).toHaveProperty('id', 'db-1')
})
})
})
```
--------------------------------------------------------------------------------
/examples/petstore-server.cjs:
--------------------------------------------------------------------------------
```
const express = require('express')
const bodyParser = require('body-parser')
// 메모리에 저장할 데이터
let pets = [
{
id: 1,
name: 'Max',
species: 'Dog',
age: 3,
status: 'available'
},
{
id: 2,
name: 'Whiskers',
species: 'Cat',
age: 2,
status: 'pending'
},
{
id: 3,
name: 'Goldie',
species: 'Fish',
age: 1,
status: 'sold'
}
]
// 다음 ID 추적용
let nextId = 4
/**
* Petstore 서버 생성 함수
* @param {number} port 서버가 실행될 포트
* @returns {Express} Express 서버 인스턴스
*/
function createPetstoreServer(port) {
const app = express()
// Middleware
app.use(bodyParser.json())
// OpenAPI spec 제공
app.get('/openapi.json', (req, res) => {
res.json({
openapi: '3.0.0',
info: {
title: 'Petstore API',
version: '1.0.0',
description: 'A simple petstore API for testing'
},
servers: [
{
url: `http://localhost:${port}`
}
],
paths: {
'/pets': {
get: {
operationId: 'listPets',
summary: 'List all pets',
parameters: [
{
name: 'status',
in: 'query',
required: false,
schema: {
type: 'string',
enum: ['available', 'pending', 'sold']
}
}
],
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: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/NewPet'
}
}
},
required: true
},
responses: {
'201': {
description: 'Pet created',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Pet'
}
}
}
}
}
}
},
'/pets/{id}': {
get: {
operationId: 'getPet',
summary: 'Get a pet by ID',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: {
type: 'integer'
}
}
],
responses: {
'200': {
description: 'A pet',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Pet'
}
}
}
},
'404': {
description: 'Pet not found',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
}
}
}
}
}
},
put: {
operationId: 'updatePet',
summary: 'Update a pet',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: {
type: 'integer'
}
}
],
requestBody: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/PetUpdate'
}
}
},
required: true
},
responses: {
'200': {
description: 'Pet updated',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Pet'
}
}
}
},
'404': {
description: 'Pet not found',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
}
}
}
}
}
},
delete: {
operationId: 'deletePet',
summary: 'Delete a pet',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: {
type: 'integer'
}
}
],
responses: {
'204': {
description: 'Pet deleted'
},
'404': {
description: 'Pet not found',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
}
}
}
}
}
}
}
},
components: {
schemas: {
Pet: {
type: 'object',
required: ['id', 'name', 'species', 'status'],
properties: {
id: {
type: 'integer'
},
name: {
type: 'string'
},
species: {
type: 'string'
},
age: {
type: 'integer'
},
status: {
type: 'string',
enum: ['available', 'pending', 'sold']
}
}
},
NewPet: {
type: 'object',
required: ['name', 'species'],
properties: {
name: {
type: 'string'
},
species: {
type: 'string'
},
age: {
type: 'integer'
}
}
},
PetUpdate: {
type: 'object',
properties: {
name: {
type: 'string'
},
species: {
type: 'string'
},
age: {
type: 'integer'
},
status: {
type: 'string',
enum: ['available', 'pending', 'sold']
}
}
},
Error: {
type: 'object',
required: ['code', 'message'],
properties: {
code: {
type: 'string'
},
message: {
type: 'string'
}
}
}
}
}
})
})
// 모든 펫 목록 조회
app.get('/pets', (req, res) => {
let result = [...pets]
// 상태별 필터링
if (req.query.status) {
result = result.filter(pet => pet.status === req.query.status)
}
res.json(result)
})
// 특정 펫 조회
app.get('/pets/:id', (req, res) => {
const id = parseInt(req.params.id)
const pet = pets.find(p => p.id === id)
if (!pet) {
return res.status(404).json({
code: 'RESOURCE_NOT_FOUND',
message: 'Pet not found',
petId: id
})
}
res.json(pet)
})
// 펫 생성
app.post('/pets', (req, res) => {
const { name, species, age } = req.body
if (!name || !species) {
return res.status(400).json({
code: 'VALIDATION_ERROR',
message: 'Name and species are required'
})
}
const newPet = {
id: nextId++,
name,
species,
age: age || 0,
status: 'available'
}
pets.push(newPet)
res.status(201).json(newPet)
})
// 펫 정보 업데이트
app.put('/pets/:id', (req, res) => {
const id = parseInt(req.params.id)
const petIndex = pets.findIndex(p => p.id === id)
if (petIndex === -1) {
return res.status(404).json({
code: 'RESOURCE_NOT_FOUND',
message: 'Pet not found',
petId: id
})
}
const { name, species, age, status } = req.body
const updatedPet = {
...pets[petIndex],
name: name !== undefined ? name : pets[petIndex].name,
species: species !== undefined ? species : pets[petIndex].species,
age: age !== undefined ? age : pets[petIndex].age,
status: status !== undefined ? status : pets[petIndex].status
}
pets[petIndex] = updatedPet
res.json(updatedPet)
})
// 펫 삭제
app.delete('/pets/:id', (req, res) => {
const id = parseInt(req.params.id)
const petIndex = pets.findIndex(p => p.id === id)
if (petIndex === -1) {
return res.status(404).json({
code: 'RESOURCE_NOT_FOUND',
message: 'Pet not found',
petId: id
})
}
pets.splice(petIndex, 1)
res.status(204).end()
})
// 서버 시작
const server = app.listen(port, () => {
console.log(`Petstore server running on http://localhost:${port}`)
})
return server
}
module.exports = { createPetstoreServer }
```
--------------------------------------------------------------------------------
/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
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: 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: 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')
}
}
```
--------------------------------------------------------------------------------
/scripts/notion-openapi.json:
--------------------------------------------------------------------------------
```json
{
"openapi": "3.1.0",
"info": {
"title": "Notion API",
"version": "1"
},
"servers": [
{
"url": "https://api.notion.com"
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer"
},
"basicAuth": {
"type": "http",
"scheme": "basic"
}
},
"parameters": {},
"schemas": {}
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/v1/blocks/{block_id}/children": {
"get": {
"summary": "Retrieve block children",
"description": "",
"operationId": "get-block-children",
"parameters": [
{
"name": "block_id",
"in": "path",
"description": "Identifier for a [block](ref:block)",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "start_cursor",
"in": "query",
"description": "If supplied, this endpoint will return a page of results starting after the cursor provided. If not supplied, this endpoint will return the first page of results.",
"schema": {
"type": "string"
}
},
{
"name": "page_size",
"in": "query",
"description": "The number of items from the full list desired in the response. Maximum: 100",
"schema": {
"type": "integer",
"format": "int32",
"default": 100
}
}
],
"responses": {},
"deprecated": false,
"security": []
}
},
"/v1/pages/{page_id}": {
"get": {
"summary": "Retrieve a page",
"description": "",
"operationId": "retrieve-a-page",
"parameters": [
{
"name": "page_id",
"in": "path",
"description": "Identifier for a Notion page",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "filter_properties",
"in": "query",
"description": "A list of page property value IDs associated with the page. Use this param to limit the response to a specific page property value or values. To retrieve multiple properties, specify each page property ID. For example: `?filter_properties=iAk8&filter_properties=b7dh`.",
"schema": {
"type": "string"
}
}
],
"responses": {},
"deprecated": false,
"security": []
}
},
"/v1/blocks/{block_id}": {
"get": {
"summary": "Retrieve a block",
"description": "",
"operationId": "retrieve-a-block",
"parameters": [
{
"name": "block_id",
"in": "path",
"description": "Identifier for a Notion block",
"schema": {
"type": "string"
},
"required": true
}
],
"responses": {},
"deprecated": false,
"security": []
}
},
"/v1/databases/{database_id}": {
"get": {
"summary": "Retrieve a database",
"description": "",
"operationId": "retrieve-a-database",
"parameters": [
{
"name": "database_id",
"in": "path",
"description": "An identifier for the Notion database.",
"schema": {
"type": "string"
},
"required": true
}
],
"responses": {
"200": {
"description": "200",
"content": {
"application/json": {
"examples": {
"Result": {
"value": "{\n \"object\": \"database\",\n \"id\": \"bc1211ca-e3f1-4939-ae34-5260b16f627c\",\n \"created_time\": \"2021-07-08T23:50:00.000Z\",\n \"last_edited_time\": \"2021-07-08T23:50:00.000Z\",\n \"icon\": {\n \"type\": \"emoji\",\n \"emoji\": \"🎉\"\n },\n \"cover\": {\n \"type\": \"external\",\n \"external\": {\n \"url\": \"https://website.domain/images/image.png\"\n }\n },\n \"url\": \"https://www.notion.so/bc1211cae3f14939ae34260b16f627c\",\n \"title\": [\n {\n \"type\": \"text\",\n \"text\": {\n \"content\": \"Grocery List\",\n \"link\": null\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \"Grocery List\",\n \"href\": null\n }\n ],\n \"description\": [\n {\n \"type\": \"text\",\n \"text\": {\n \"content\": \"Grocery list for just kale 🥬\",\n \"link\": null\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \"Grocery list for just kale 🥬\",\n \"href\": null\n }\n ],\n \"properties\": {\n \"+1\": {\n \"id\": \"Wp%3DC\",\n \"name\": \"+1\",\n \"type\": \"people\",\n \"people\": {}\n },\n \"In stock\": {\n \"id\": \"fk%5EY\",\n \"name\": \"In stock\",\n \"type\": \"checkbox\",\n \"checkbox\": {}\n },\n \"Price\": {\n \"id\": \"evWq\",\n \"name\": \"Price\",\n \"type\": \"number\",\n \"number\": {\n \"format\": \"dollar\"\n }\n },\n \"Description\": {\n \"id\": \"V}lX\",\n \"name\": \"Description\",\n \"type\": \"rich_text\",\n \"rich_text\": {}\n },\n \"Last ordered\": {\n \"id\": \"eVnV\",\n \"name\": \"Last ordered\",\n \"type\": \"date\",\n \"date\": {}\n },\n \"Meals\": {\n \"id\": \"%7DWA~\",\n \"name\": \"Meals\",\n \"type\": \"relation\",\n \"relation\": {\n \"database_id\": \"668d797c-76fa-4934-9b05-ad288df2d136\",\n \"synced_property_name\": \"Related to Grocery List (Meals)\"\n }\n },\n \"Number of meals\": {\n \"id\": \"Z\\\\Eh\",\n \"name\": \"Number of meals\",\n \"type\": \"rollup\",\n \"rollup\": {\n \"rollup_property_name\": \"Name\",\n \"relation_property_name\": \"Meals\",\n \"rollup_property_id\": \"title\",\n \"relation_property_id\": \"mxp^\",\n \"function\": \"count\"\n }\n },\n \"Store availability\": {\n \"id\": \"s}Kq\",\n \"name\": \"Store availability\",\n \"type\": \"multi_select\",\n \"multi_select\": {\n \"options\": [\n {\n \"id\": \"cb79b393-d1c1-4528-b517-c450859de766\",\n \"name\": \"Duc Loi Market\",\n \"color\": \"blue\"\n },\n {\n \"id\": \"58aae162-75d4-403b-a793-3bc7308e4cd2\",\n \"name\": \"Rainbow Grocery\",\n \"color\": \"gray\"\n },\n {\n \"id\": \"22d0f199-babc-44ff-bd80-a9eae3e3fcbf\",\n \"name\": \"Nijiya Market\",\n \"color\": \"purple\"\n },\n {\n \"id\": \"0d069987-ffb0-4347-bde2-8e4068003dbc\",\n \"name\": \"Gus's Community Market\",\n \"color\": \"yellow\"\n }\n ]\n }\n },\n \"Photo\": {\n \"id\": \"yfiK\",\n \"name\": \"Photo\",\n \"type\": \"files\",\n \"files\": {}\n },\n \"Food group\": {\n \"id\": \"CM%3EH\",\n \"name\": \"Food group\",\n \"type\": \"select\",\n \"select\": {\n \"options\": [\n {\n \"id\": \"6d4523fa-88cb-4ffd-9364-1e39d0f4e566\",\n \"name\": \"🥦Vegetable\",\n \"color\": \"green\"\n },\n {\n \"id\": \"268d7e75-de8f-4c4b-8b9d-de0f97021833\",\n \"name\": \"🍎Fruit\",\n \"color\": \"red\"\n },\n {\n \"id\": \"1b234a00-dc97-489c-b987-829264cfdfef\",\n \"name\": \"💪Protein\",\n \"color\": \"yellow\"\n }\n ]\n }\n },\n \"Name\": {\n \"id\": \"title\",\n \"name\": \"Name\",\n \"type\": \"title\",\n \"title\": {}\n }\n },\n \"parent\": {\n \"type\": \"page_id\",\n \"page_id\": \"98ad959b-2b6a-4774-80ee-00246fb0ea9b\"\n },\n \"archived\": false,\n \"is_inline\": false,\n \"public_url\": null\n}"
}
}
}
}
}
},
"deprecated": false,
"security": []
}
},
"/v1/comments": {
"get": {
"summary": "Retrieve comments",
"description": "Retrieves a list of un-resolved [Comment objects](ref:comment-object) from a page or block.",
"operationId": "retrieve-a-comment",
"parameters": [
{
"name": "block_id",
"in": "query",
"description": "Identifier for a Notion block or page",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "start_cursor",
"in": "query",
"description": "If supplied, this endpoint will return a page of results starting after the cursor provided. If not supplied, this endpoint will return the first page of results.",
"schema": {
"type": "string"
}
},
{
"name": "page_size",
"in": "query",
"description": "The number of items from the full list desired in the response. Maximum: 100",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "200",
"content": {
"application/json": {
"examples": {
"OK": {
"value": "{\n \"object\": \"list\",\n \"results\": [\n {\n \"object\": \"comment\",\n \"id\": \"94cc56ab-9f02-409d-9f99-1037e9fe502f\",\n \"parent\": {\n \"type\": \"page_id\",\n \"page_id\": \"5c6a2821-6bb1-4a7e-b6e1-c50111515c3d\"\n },\n \"discussion_id\": \"f1407351-36f5-4c49-a13c-49f8ba11776d\",\n \"created_time\": \"2022-07-15T16:52:00.000Z\",\n \"last_edited_time\": \"2022-07-15T19:16:00.000Z\",\n \"created_by\": {\n \"object\": \"user\",\n \"id\": \"9b15170a-9941-4297-8ee6-83fa7649a87a\"\n },\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"text\": {\n \"content\": \"Single comment\",\n \"link\": null\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \"Single comment\",\n \"href\": null\n }\n ]\n }\n ],\n \"next_cursor\": null,\n \"has_more\": false,\n \"type\": \"comment\",\n \"comment\": {}\n}"
}
}
}
}
}
},
"deprecated": false,
"security": []
}
},
"/v1/pages/{page_id}/properties/{property_id}": {
"get": {
"summary": "Retrieve a page property item",
"description": "",
"operationId": "retrieve-a-page-property",
"parameters": [
{
"name": "page_id",
"in": "path",
"description": "Identifier for a Notion page",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "property_id",
"in": "path",
"description": "Identifier for a page [property](https://developers.notion.com/reference/page#all-property-values)",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "page_size",
"in": "query",
"description": "For paginated properties. The max number of property item objects on a page. The default size is 100",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "start_cursor",
"in": "query",
"description": "For paginated properties.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "200",
"content": {
"application/json": {
"examples": {
"Number Property Item": {
"value": "{\n \"object\": \"property_item\",\n \"id\" \"kjPO\",\n \"type\": \"number\",\n \"number\": 2\n}"
},
"Result": {
"value": "{\n \"object\": \"list\",\n \"results\": [\n {\n \"object\": \"property_item\",\n \"id\" \"kjPO\",\n \"type\": \"rich_text\",\n \"rich_text\": {\n \"type\": \"text\",\n \"text\": {\n \"content\": \"Avocado \",\n \"link\": null\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \"Avocado \",\n \"href\": null\n }\n },\n {\n \"object\": \"property_item\",\n \"id\" \"ijPO\",\n \"type\": \"rich_text\",\n \"rich_text\": {\n \"type\": \"mention\",\n \"mention\": {\n \"type\": \"page\",\n \"page\": {\n \"id\": \"41117fd7-69a5-4694-bc07-c1e3a682c857\"\n }\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \"Lemons\",\n \"href\": \"http://notion.so/41117fd769a54694bc07c1e3a682c857\"\n }\n },\n {\n \"object\": \"property_item\",\n \"id\" \"kjPO\",\n \"type\": \"rich_text\",\n \"rich_text\": {\n \"type\": \"text\",\n \"text\": {\n \"content\": \" Tomato \",\n \"link\": null\n },\n \"annotations\": {\n \"bold\": false,\n \"italic\": false,\n \"strikethrough\": false,\n \"underline\": false,\n \"code\": false,\n \"color\": \"default\"\n },\n \"plain_text\": \" Tomato \",\n \"href\": null\n }\n },\n...\n ],\n \"next_cursor\": \"some-next-cursor-value\",\n \"has_more\": true,\n\t\t\"next_url\": \"http://api.notion.com/v1/pages/0e5235bf86aa4efb93aa772cce7eab71/properties/NVv^?start_cursor=some-next-cursor-value&page_size=25\",\n \"property_item\": {\n \"id\": \"NVv^\",\n \"next_url\": null,\n \"type\": \"rich_text\",\n \"rich_text\": {}\n }\n}"
},
"Rollup List Property Item": {
"value": "{\n \"object\": \"list\",\n \"results\": [\n {\n \"object\": \"property_item\",\n \t\"id\": \"dj2l\",\n \"type\": \"relation\",\n \"relation\": {\n \"id\": \"83f92c9d-523d-466e-8c1f-9bc2c25a99fe\"\n }\n },\n {\n \"object\": \"property_item\",\n \t\"id\": \"dj2l\",\n \"type\": \"relation\",\n \"relation\": {\n \"id\": \"45cfb825-3463-4891-8932-7e6d8c170630\"\n }\n },\n {\n \"object\": \"property_item\",\n \t\"id\": \"dj2l\",\n \"type\": \"relation\",\n \"relation\": {\n \"id\": \"1688be1a-a197-4f2a-9688-e528c4b56d94\"\n }\n }\n ],\n \"next_cursor\": \"some-next-cursor-value\",\n \"has_more\": true,\n\t\t\"property_item\": {\n \"id\": \"y}~p\",\n \"next_url\": \"http://api.notion.com/v1/pages/0e5235bf86aa4efb93aa772cce7eab71/properties/y%7D~p?start_cursor=1QaTunT5&page_size=25\",\n \"type\": \"rollup\",\n \"rollup\": {\n \"function\": \"sum\",\n \"type\": \"incomplete\",\n \"incomplete\": {}\n }\n }\n \"type\": \"property_item\"\n}"
}
}
}
}
}
},
"deprecated": false,
"security": []
}
}
}
}
```
--------------------------------------------------------------------------------
/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/mcp/proxy.ts:
--------------------------------------------------------------------------------
```typescript
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
import { JSONSchema7 as IJsonSchema } from 'json-schema'
import { OpenAPIV3 } from 'openapi-types'
import { HttpClient, HttpClientError } from '../client/http-client'
import { OpenAPIToMCPConverter } from '../openapi/parser'
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
}>
}
// Notion object type definition
interface NotionBlock {
object: 'block';
id: string;
type: string;
has_children?: boolean;
[key: string]: any; // Allow additional fields
}
interface NotionPage {
object: 'page';
id: string;
properties: Record<string, any>;
[key: string]: any; // Allow additional fields
}
interface NotionDatabase {
object: 'database';
id: string;
properties: Record<string, any>;
[key: string]: any; // Allow additional fields
}
interface NotionComment {
object: 'comment';
id: string;
[key: string]: any; // Allow additional fields
}
type NotionObject = NotionBlock | NotionPage | NotionDatabase | NotionComment;
// Recursive exploration options
interface RecursiveExplorationOptions {
maxDepth?: number;
includeDatabases?: boolean;
includeComments?: boolean;
includeProperties?: boolean;
maxParallelRequests?: number;
skipCache?: boolean;
batchSize?: number;
timeoutMs?: number;
runInBackground?: boolean;
}
// 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 }>
private pageCache: Map<string, any> = new Map() // Cache for performance improvement
private blockCache: Map<string, any> = new Map() // Block cache
private databaseCache: Map<string, any> = new Map() // Database cache
private commentCache: Map<string, any> = new Map() // Comment cache
private propertyCache: Map<string, any> = new Map() // Property cache
private backgroundProcessingResults: Map<string, any> = new Map()
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[] = []
// Log available tools
console.log('One Pager Assistant - Available tools:')
// 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'],
})
console.log(`- ${truncatedToolName}: ${method.description}`)
})
})
// Add extended One Pager tool
const onePagerTool = {
name: 'API-get-one-pager',
description: 'Recursively retrieve a full Notion page with all its blocks, databases, and related content',
inputSchema: {
type: 'object',
properties: {
page_id: {
type: 'string',
description: 'Identifier for a Notion page',
},
maxDepth: {
type: 'integer',
description: 'Maximum recursion depth (default: 5)',
},
includeDatabases: {
type: 'boolean',
description: 'Whether to include linked databases (default: true)',
},
includeComments: {
type: 'boolean',
description: 'Whether to include comments (default: true)',
},
includeProperties: {
type: 'boolean',
description: 'Whether to include detailed page properties (default: true)',
},
maxParallelRequests: {
type: 'integer',
description: 'Maximum number of parallel requests (default: 15)',
},
batchSize: {
type: 'integer',
description: 'Batch size for parallel processing (default: 10)',
},
timeoutMs: {
type: 'integer',
description: 'Timeout in milliseconds (default: 300000)',
},
runInBackground: {
type: 'boolean',
description: 'Process request in background without timeout (default: true)',
}
},
required: ['page_id'],
} as Tool['inputSchema'],
};
tools.push(onePagerTool);
console.log(`- ${onePagerTool.name}: ${onePagerTool.description}`);
// Add tool to retrieve background processing results
const backgroundResultTool = {
name: 'API-get-background-result',
description: 'Retrieve the result of a background processing request',
inputSchema: {
type: 'object',
properties: {
page_id: {
type: 'string',
description: 'Identifier for the Notion page that was processed in background',
},
},
required: ['page_id'],
} as Tool['inputSchema'],
};
tools.push(backgroundResultTool);
console.log(`- ${backgroundResultTool.name}: ${backgroundResultTool.description}`);
return { tools }
})
// Handle tool calling
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: params } = request.params
console.log(`One Pager Assistant - Tool call: ${name}`)
console.log('Parameters:', JSON.stringify(params, null, 2))
try {
// Handle extended One Pager tool
if (name === 'API-get-one-pager') {
return await this.handleOnePagerRequest(params);
}
// Handle background result retrieval
if (name === 'API-get-background-result') {
const result = this.getBackgroundProcessingResult(params?.page_id as string);
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
}
// Find the operation in OpenAPI spec
const operation = this.findOperation(name)
if (!operation) {
const error = `Method ${name} not found.`
console.error(error)
return {
content: [
{
type: 'text',
text: JSON.stringify({
status: 'error',
message: error,
code: 404
}),
},
],
}
}
// Optimized parallel processing for API-get-block-children
if (name === 'API-get-block-children') {
// Create basic options for logging control
const blockOptions: RecursiveExplorationOptions = {
runInBackground: false, // Default to not showing logs for regular API calls
};
return await this.handleBlockChildrenParallel(operation, params, blockOptions);
}
// Other regular API calls
console.log(`Notion API call: ${operation.method.toUpperCase()} ${operation.path}`)
const response = await this.httpClient.executeOperation(operation, params)
// Log response summary
console.log('Notion API response code:', response.status)
if (response.status !== 200) {
console.error('Response error:', response.data)
} else {
console.log('Response success')
}
// Update cache with response data
this.updateCacheFromResponse(name, response.data);
// Convert response to MCP format
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data),
},
],
}
} catch (error) {
console.error('Tool call error', error)
if (error instanceof HttpClientError) {
console.error('HttpClientError occurred, returning structured error', error)
const data = error.data?.response?.data ?? error.data ?? {}
return {
content: [
{
type: 'text',
text: JSON.stringify({
status: 'error',
code: error.status,
message: error.message,
details: typeof data === 'object' ? data : { data: data },
}),
},
],
}
}
// Ensure any other errors are also properly formatted as JSON
return {
content: [
{
type: 'text',
text: JSON.stringify({
status: 'error',
message: error instanceof Error ? error.message : String(error),
code: 500
}),
},
],
}
}
})
}
// Update cache based on API response type
private updateCacheFromResponse(apiName: string, data: any): void {
if (!data || typeof data !== 'object') return;
try {
// Update appropriate cache based on API response type
if (apiName === 'API-retrieve-a-page' && data.object === 'page' && data.id) {
this.pageCache.set(data.id, data);
} else if (apiName === 'API-retrieve-a-block' && data.object === 'block' && data.id) {
this.blockCache.set(data.id, data);
} else if (apiName === 'API-retrieve-a-database' && data.object === 'database' && data.id) {
this.databaseCache.set(data.id, data);
} else if (apiName === 'API-retrieve-a-comment' && data.results) {
// Cache comments from result list
data.results.forEach((comment: any) => {
if (comment.object === 'comment' && comment.id) {
this.commentCache.set(comment.id, comment);
}
});
} else if (apiName === 'API-retrieve-a-page-property' && data.results) {
// Page property caching - would need params from call context
// Skip this in current context
console.log('Page property information has been cached');
}
// API-get-block-children handled in handleBlockChildrenParallel
} catch (error) {
console.warn('Error updating cache:', error);
}
}
// One Pager request handler
private async handleOnePagerRequest(params: any) {
if (params.runInBackground !== false) {
console.log('Starting One Pager request processing:', params.page_id);
}
const options: RecursiveExplorationOptions = {
maxDepth: params.maxDepth || 5,
includeDatabases: params.includeDatabases !== false,
includeComments: params.includeComments !== false,
includeProperties: params.includeProperties !== false,
maxParallelRequests: params.maxParallelRequests || 15,
skipCache: params.skipCache || false,
batchSize: params.batchSize || 10,
timeoutMs: params.timeoutMs || 300000, // Increased timeout to 5 minutes (300000ms)
runInBackground: params.runInBackground !== false,
};
if (options.runInBackground) {
console.log('Exploration options:', JSON.stringify(options, null, 2));
}
try {
const startTime = Date.now();
// Check if we should run in background mode
if (options.runInBackground) {
// Return immediately with a background processing message
// The actual processing will continue in the background
this.runBackgroundProcessing(params.page_id, options);
return {
content: [
{
type: 'text',
text: JSON.stringify({
status: 'processing',
message: `Request processing for page ${params.page_id} started in background`,
page_id: params.page_id,
request_time: new Date().toISOString(),
options: {
maxDepth: options.maxDepth,
includeDatabases: options.includeDatabases,
includeComments: options.includeComments,
includeProperties: options.includeProperties,
timeoutMs: options.timeoutMs
}
}),
},
],
};
}
// Foreground processing (standard behavior)
const pageData = await this.retrievePageRecursively(params.page_id, options);
const duration = Date.now() - startTime;
if (options.runInBackground) {
console.log(`One Pager completed in ${duration}ms for page ${params.page_id}`);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
...pageData,
_meta: {
processingTimeMs: duration,
retrievedAt: new Date().toISOString(),
options: {
maxDepth: options.maxDepth,
includeDatabases: options.includeDatabases,
includeComments: options.includeComments,
includeProperties: options.includeProperties
}
}
}),
},
],
};
} catch (error) {
if (options.runInBackground) {
console.error('Error in One Pager request:', error);
}
const errorResponse = {
status: 'error',
message: error instanceof Error ? error.message : String(error),
code: error instanceof HttpClientError ? error.status : 500,
details: error instanceof HttpClientError ? error.data : undefined,
timestamp: new Date().toISOString()
};
return {
content: [
{
type: 'text',
text: JSON.stringify(errorResponse),
},
],
};
}
}
// New method to run processing in background
private runBackgroundProcessing(pageId: string, options: RecursiveExplorationOptions): void {
// Use setTimeout to detach from the current execution context
setTimeout(async () => {
try {
console.log(`Background processing started for page ${pageId}`);
const startTime = Date.now();
// Execute the recursive page retrieval without time restrictions
const noTimeoutOptions = { ...options, timeoutMs: 0 }; // 0 means no timeout
const pageData = await this.retrievePageRecursively(pageId, noTimeoutOptions);
const duration = Date.now() - startTime;
console.log(`Background processing completed in ${duration}ms for page ${pageId}`);
// Store the result in cache for later retrieval
this.storeBackgroundProcessingResult(pageId, {
...pageData,
_meta: {
processingTimeMs: duration,
retrievedAt: new Date().toISOString(),
processedInBackground: true,
options: {
maxDepth: options.maxDepth,
includeDatabases: options.includeDatabases,
includeComments: options.includeComments,
includeProperties: options.includeProperties
}
}
});
} catch (error) {
console.error(`Background processing error for page ${pageId}:`, error);
// Store error result for later retrieval
this.storeBackgroundProcessingResult(pageId, {
status: 'error',
page_id: pageId,
message: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString()
});
}
}, 0);
}
// Store background processing results
private storeBackgroundProcessingResult(pageId: string, result: any): void {
this.backgroundProcessingResults.set(pageId, result);
}
// Add a new tool method to retrieve background processing results
public getBackgroundProcessingResult(pageId: string): any {
return this.backgroundProcessingResults.get(pageId) || {
status: 'not_found',
message: `No background processing result found for page ${pageId}`
};
}
// Recursively retrieve page content
private async retrievePageRecursively(pageId: string, options: RecursiveExplorationOptions, currentDepth: number = 0): Promise<any> {
if (options.runInBackground) {
console.log(`Recursive page exploration: ${pageId}, depth: ${currentDepth}/${options.maxDepth || 5}`);
}
const timeoutPromise = new Promise<never>((_, reject) => {
if (options.timeoutMs && options.timeoutMs > 0) {
setTimeout(() => reject(new Error(`Operation timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
}
});
try {
// Check maximum depth
if (currentDepth >= (options.maxDepth || 5)) {
if (options.runInBackground) {
console.log(`Maximum depth reached: ${currentDepth}/${options.maxDepth || 5}`);
}
return { id: pageId, note: "Maximum recursion depth reached" };
}
// 1. Get basic page info (check cache)
let pageData: any;
if (!options.skipCache && this.pageCache.has(pageId)) {
pageData = this.pageCache.get(pageId);
if (options.runInBackground) {
console.log(`Page cache hit: ${pageId}`);
}
} else {
// Retrieve page info via API call
const operation = this.findOperation('API-retrieve-a-page');
if (!operation) {
throw new Error('API-retrieve-a-page method not found.');
}
if (options.runInBackground) {
console.log(`Notion API call: ${operation.method.toUpperCase()} ${operation.path} (pageId: ${pageId})`);
}
// Only race with timeout if timeoutMs is set
let response;
if (options.timeoutMs && options.timeoutMs > 0) {
response = await Promise.race([
this.httpClient.executeOperation(operation, { page_id: pageId }),
timeoutPromise
]) as any;
} else {
response = await this.httpClient.executeOperation(operation, { page_id: pageId });
}
if (response.status !== 200) {
if (options.runInBackground) {
console.error('Error retrieving page information:', response.data);
}
return {
id: pageId,
error: "Failed to retrieve page",
status: response.status,
details: response.data
};
}
pageData = response.data;
// Only cache successful responses
this.pageCache.set(pageId, pageData);
}
// Collection of tasks to be executed in parallel for improved efficiency
const parallelTasks: Promise<any>[] = [];
// 2. Fetch block content (register async task)
const blocksPromise = this.retrieveBlocksRecursively(pageId, options, currentDepth + 1);
parallelTasks.push(blocksPromise);
// 3. Fetch property details (if option enabled)
let propertiesPromise: Promise<any> = Promise.resolve(null);
if (options.includeProperties && pageData.properties) {
propertiesPromise = this.enrichPageProperties(pageId, pageData.properties, options);
parallelTasks.push(propertiesPromise);
}
// 4. Fetch comments (if option enabled)
let commentsPromise: Promise<any> = Promise.resolve(null);
if (options.includeComments) {
commentsPromise = this.retrieveComments(pageId, options);
parallelTasks.push(commentsPromise);
}
// Execute all tasks in parallel
if (options.timeoutMs && options.timeoutMs > 0) {
await Promise.race([Promise.all(parallelTasks), timeoutPromise]);
} else {
await Promise.all(parallelTasks);
}
// Integrate results into the main page data
const enrichedPageData = { ...pageData };
// Add block content
const blocksData = await blocksPromise;
enrichedPageData.content = blocksData;
// Add property details (if option enabled)
if (options.includeProperties && pageData.properties) {
const enrichedProperties = await propertiesPromise;
if (enrichedProperties) {
enrichedPageData.detailed_properties = enrichedProperties;
}
}
// Add comments (if option enabled)
if (options.includeComments) {
const comments = await commentsPromise;
if (comments && comments.results && comments.results.length > 0) {
enrichedPageData.comments = comments;
}
}
return enrichedPageData;
} catch (error) {
if (error instanceof Error && error.message.includes('timed out')) {
if (options.runInBackground) {
console.error(`Timeout occurred while processing page ${pageId} at depth ${currentDepth}`);
}
return {
id: pageId,
error: "Operation timed out",
partial_results: true,
note: `Processing exceeded timeout limit (${options.timeoutMs}ms)`
};
}
if (options.runInBackground) {
console.error(`Error in retrievePageRecursively for page ${pageId}:`, error);
}
return {
id: pageId,
error: error instanceof Error ? error.message : String(error),
retrievalFailed: true
};
}
}
// Recursively retrieve block content with improved parallelism
private async retrieveBlocksRecursively(blockId: string, options: RecursiveExplorationOptions, currentDepth: number): Promise<any[]> {
if (options.runInBackground) {
console.log(`Recursive block exploration: ${blockId}, depth: ${currentDepth}/${options.maxDepth || 5}`);
}
if (currentDepth >= (options.maxDepth || 5)) {
if (options.runInBackground) {
console.log(`Maximum depth reached: ${currentDepth}/${options.maxDepth || 5}`);
}
return [{ note: "Maximum recursion depth reached" }];
}
try {
const operation = this.findOperation('API-get-block-children');
if (!operation) {
throw new Error('API-get-block-children method not found.');
}
const blocksResponse = await this.handleBlockChildrenParallel(operation, {
block_id: blockId,
page_size: 100
}, options);
const blocksData = JSON.parse(blocksResponse.content[0].text);
const blocks = blocksData.results || [];
if (blocks.length === 0) {
return [];
}
const batchSize = options.batchSize || 10;
const enrichedBlocks: any[] = [];
// Process blocks in batches for memory optimization and improved parallel execution
for (let i = 0; i < blocks.length; i += batchSize) {
const batch = blocks.slice(i, i + batchSize);
// Process each batch in parallel
const batchResults = await Promise.all(
batch.map(async (block: any) => {
this.blockCache.set(block.id, block);
const enrichedBlock = { ...block };
// Collection of async tasks for this block
const blockTasks: Promise<any>[] = [];
// Process child blocks recursively
if (block.has_children) {
blockTasks.push(
this.retrieveBlocksRecursively(block.id, options, currentDepth + 1)
.then(childBlocks => { enrichedBlock.children = childBlocks; })
.catch(error => {
console.error(`Error retrieving child blocks for ${block.id}:`, error);
enrichedBlock.children_error = { message: String(error) };
return [];
})
);
}
// Process database blocks (if option enabled)
if (options.includeDatabases &&
(block.type === 'child_database' || block.type === 'linked_database')) {
const databaseId = block[block.type]?.database_id;
if (databaseId) {
blockTasks.push(
this.retrieveDatabase(databaseId, options)
.then(database => { enrichedBlock.database = database; })
.catch(error => {
console.error(`Error retrieving database ${databaseId}:`, error);
enrichedBlock.database_error = { message: String(error) };
})
);
}
}
// Process page blocks or linked pages - optimization
if (block.type === 'child_page' && currentDepth < (options.maxDepth || 5) - 1) {
const pageId = block.id;
blockTasks.push(
this.retrievePageBasicInfo(pageId, options)
.then(pageInfo => { enrichedBlock.page_info = pageInfo; })
.catch(error => {
console.error(`Error retrieving page info for ${pageId}:`, error);
enrichedBlock.page_info_error = { message: String(error) };
})
);
}
// Wait for all async tasks to complete
if (blockTasks.length > 0) {
await Promise.all(blockTasks);
}
return enrichedBlock;
})
);
enrichedBlocks.push(...batchResults);
}
return enrichedBlocks;
} catch (error) {
console.error(`Error in retrieveBlocksRecursively for block ${blockId}:`, error);
return [{
id: blockId,
error: error instanceof Error ? error.message : String(error),
retrievalFailed: true
}];
}
}
// Lightweight method to fetch only basic page info (without recursive loading)
private async retrievePageBasicInfo(pageId: string, options: RecursiveExplorationOptions): Promise<any> {
// Check cache
if (!options.skipCache && this.pageCache.has(pageId)) {
const cachedData = this.pageCache.get(pageId);
return {
id: cachedData.id,
title: cachedData.properties?.title || { text: null },
icon: cachedData.icon,
cover: cachedData.cover,
url: cachedData.url,
fromCache: true
};
}
// Get page info via API
const operation = this.findOperation('API-retrieve-a-page');
if (!operation) {
return { id: pageId, note: "API-retrieve-a-page method not found" };
}
try {
const response = await this.httpClient.executeOperation(operation, { page_id: pageId });
if (response.status !== 200) {
return { id: pageId, error: "Failed to retrieve page", status: response.status };
}
const pageData = response.data;
this.pageCache.set(pageId, pageData);
return {
id: pageData.id,
title: pageData.properties?.title || { text: null },
icon: pageData.icon,
cover: pageData.cover,
url: pageData.url,
created_time: pageData.created_time,
last_edited_time: pageData.last_edited_time
};
} catch (error) {
console.error(`Error retrieving basic page info ${pageId}:`, error);
return { id: pageId, error: error instanceof Error ? error.message : String(error) };
}
}
// Retrieve database information
private async retrieveDatabase(databaseId: string, options: RecursiveExplorationOptions): Promise<any> {
console.log(`Retrieving database information: ${databaseId}`);
// Check cache
if (!options.skipCache && this.databaseCache.has(databaseId)) {
console.log(`Database cache hit: ${databaseId}`);
return this.databaseCache.get(databaseId);
}
// Get database info via API call
const operation = this.findOperation('API-retrieve-a-database');
if (!operation) {
console.warn('API-retrieve-a-database method not found.');
return { id: databaseId, note: "Database details not available" };
}
try {
console.log(`Notion API call: ${operation.method.toUpperCase()} ${operation.path} (databaseId: ${databaseId})`);
const response = await this.httpClient.executeOperation(operation, { database_id: databaseId });
if (response.status !== 200) {
console.error('Error retrieving database information:', response.data);
return { id: databaseId, error: "Failed to retrieve database" };
}
const databaseData = response.data;
this.databaseCache.set(databaseId, databaseData);
return databaseData;
} catch (error) {
console.error('Error retrieving database:', error);
return { id: databaseId, error: "Failed to retrieve database" };
}
}
// Retrieve comments
private async retrieveComments(blockId: string, options: RecursiveExplorationOptions): Promise<any> {
if (options.runInBackground) {
console.log(`Retrieving comments: ${blockId}`);
}
// Get comments via API call
const operation = this.findOperation('API-retrieve-a-comment');
if (!operation) {
if (options.runInBackground) {
console.warn('API-retrieve-a-comment method not found.');
}
return Promise.resolve({ results: [] });
}
try {
if (options.runInBackground) {
console.log(`Notion API call: ${operation.method.toUpperCase()} ${operation.path} (blockId: ${blockId})`);
}
return this.httpClient.executeOperation(operation, { block_id: blockId })
.then(response => {
if (response.status !== 200) {
if (options.runInBackground) {
console.error('Error retrieving comments:', response.data);
}
return { results: [] };
}
const commentsData = response.data;
// Cache comments
if (commentsData.results) {
commentsData.results.forEach((comment: any) => {
if (comment.id) {
this.commentCache.set(comment.id, comment);
}
});
}
return commentsData;
})
.catch(error => {
if (options.runInBackground) {
console.error('Error retrieving comments:', error);
}
return { results: [] };
});
} catch (error) {
if (options.runInBackground) {
console.error('Error retrieving comments:', error);
}
return Promise.resolve({ results: [] });
}
}
// Enrich page properties with detailed information
private async enrichPageProperties(pageId: string, properties: any, options: RecursiveExplorationOptions): Promise<any> {
if (options.runInBackground) {
console.log(`Enriching page properties: ${pageId}`);
}
const enrichedProperties = { ...properties };
const propertyPromises: Promise<void>[] = [];
// Get detailed information for each property
for (const [propName, propData] of Object.entries(properties)) {
const propId = (propData as any).id;
if (!propId) continue;
// Create cache key
const cacheKey = `${pageId}:${propId}`;
propertyPromises.push(
(async () => {
try {
// Check cache
if (!options.skipCache && this.propertyCache.has(cacheKey)) {
enrichedProperties[propName].details = this.propertyCache.get(cacheKey);
} else {
// Skip properties with URLs that contain special characters like notion://
if (propId.includes('notion://') || propId.includes('%3A%2F%2F')) {
if (options.runInBackground) {
console.warn(`Skipping property with special URL format: ${propName} (${propId})`);
}
enrichedProperties[propName].details = {
object: 'property_item',
type: 'unsupported',
unsupported: { type: 'special_url_format' }
};
return;
}
// Get property details via API call
const operation = this.findOperation('API-retrieve-a-page-property');
if (!operation) {
if (options.runInBackground) {
console.warn('API-retrieve-a-page-property method not found.');
}
return;
}
const response = await this.httpClient.executeOperation(operation, {
page_id: pageId,
property_id: propId
}).catch(error => {
if (options.runInBackground) {
console.warn(`Error retrieving property ${propName} (${propId}): ${error.message}`);
}
return {
status: error.status || 500,
data: {
object: 'property_item',
type: 'error',
error: { message: error.message }
}
};
});
if (response.status === 200) {
enrichedProperties[propName].details = response.data;
this.propertyCache.set(cacheKey, response.data);
} else {
enrichedProperties[propName].details = {
object: 'property_item',
type: 'error',
error: { status: response.status, message: JSON.stringify(response.data) }
};
}
}
} catch (error) {
if (options.runInBackground) {
console.error(`Error retrieving property ${propName}:`, error);
}
enrichedProperties[propName].details = {
object: 'property_item',
type: 'error',
error: { message: error instanceof Error ? error.message : String(error) }
};
}
})()
);
}
// Get all property information in parallel
await Promise.all(propertyPromises);
return enrichedProperties;
}
// Optimized parallel processing for block children
private async handleBlockChildrenParallel(
operation: OpenAPIV3.OperationObject & { method: string; path: string },
params: any,
options?: RecursiveExplorationOptions
) {
if (options?.runInBackground) {
console.log(`Starting Notion API parallel processing: ${operation.method.toUpperCase()} ${operation.path}`);
}
// Get first page
const initialResponse = await this.httpClient.executeOperation(operation, params);
if (initialResponse.status !== 200) {
if (options?.runInBackground) {
console.error('Response error:', initialResponse.data);
}
return {
content: [{ type: 'text', text: JSON.stringify(initialResponse.data) }],
};
}
const results = initialResponse.data.results || [];
let nextCursor = initialResponse.data.next_cursor;
// Array for parallel processing
const pageRequests = [];
const maxParallelRequests = 5; // Limit simultaneous requests
if (options?.runInBackground) {
console.log(`Retrieved ${results.length} blocks from first page`);
}
// Request subsequent pages in parallel if available
while (nextCursor) {
// Clone parameters for next page
const nextPageParams = { ...params, start_cursor: nextCursor };
// Add page request
pageRequests.push(
this.httpClient.executeOperation(operation, nextPageParams)
.then(response => {
if (response.status === 200) {
if (options?.runInBackground) {
console.log(`Retrieved ${response.data.results?.length || 0} blocks from additional page`);
}
return {
results: response.data.results || [],
next_cursor: response.data.next_cursor
};
}
return { results: [], next_cursor: null };
})
.catch(error => {
if (options?.runInBackground) {
console.error('Error retrieving page:', error);
}
return { results: [], next_cursor: null };
})
);
// Execute parallel requests when batch size reached or no more pages
if (pageRequests.length >= maxParallelRequests || !nextCursor) {
if (options?.runInBackground) {
console.log(`Processing ${pageRequests.length} pages in parallel...`);
}
const pageResponses = await Promise.all(pageRequests);
// Merge results
for (const response of pageResponses) {
results.push(...response.results);
// Set next cursor for next batch
if (response.next_cursor) {
nextCursor = response.next_cursor;
} else {
nextCursor = null;
}
}
// Reset request array
pageRequests.length = 0;
}
// Exit loop if no more pages
if (!nextCursor) break;
}
if (options?.runInBackground) {
console.log(`Retrieved ${results.length} blocks in total`);
}
// Return merged response
const mergedResponse = {
...initialResponse.data,
results,
has_more: false,
next_cursor: null
};
return {
content: [{ type: 'text', text: JSON.stringify(mergedResponse) }],
};
}
private findOperation(operationId: string): (OpenAPIV3.OperationObject & { method: string; path: string }) | null {
return this.openApiLookup[operationId] ?? null
}
private parseHeadersFromEnv(): Record<string, string> {
const headersJson = process.env.OPENAPI_MCP_HEADERS
if (!headersJson) {
return {}
}
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)
return {}
}
return headers
} catch (error) {
console.warn('Failed to parse OPENAPI_MCP_HEADERS environment variable:', error)
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) {
console.log('One Pager Assistant - MCP server started')
console.log('Providing APIs: retrieve-a-page, get-block-children, retrieve-a-block')
console.log('New feature: get-one-pager - recursively explore pages automatically')
console.log('Parallel processing optimization enabled')
// The SDK will handle stdio communication
await this.server.connect(transport)
}
getServer() {
return this.server
}
}
```
--------------------------------------------------------------------------------
/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)
})
})
```