This is page 2 of 2. Use http://codebase.md/taewoong1378/notion-readonly-mcp-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .dockerignore
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── docs
│ └── images
│ ├── connections.png
│ ├── integrations-capabilities.png
│ ├── integrations-creation.png
│ └── notion-api-tools-comparison.png
├── examples
│ └── petstore-server.cjs
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── scripts
│ ├── build-cli.js
│ ├── notion-openapi.json
│ └── start-server.ts
├── src
│ ├── init-server.ts
│ └── openapi-mcp-server
│ ├── auth
│ │ ├── index.ts
│ │ ├── template.ts
│ │ └── types.ts
│ ├── client
│ │ ├── __tests__
│ │ │ ├── http-client-upload.test.ts
│ │ │ ├── http-client.integration.test.ts
│ │ │ └── http-client.test.ts
│ │ └── http-client.ts
│ ├── index.ts
│ ├── mcp
│ │ ├── __tests__
│ │ │ ├── one-pager.test.ts
│ │ │ └── proxy.test.ts
│ │ └── proxy.ts
│ ├── openapi
│ │ ├── __tests__
│ │ │ ├── file-upload.test.ts
│ │ │ ├── parser-multipart.test.ts
│ │ │ └── parser.test.ts
│ │ ├── file-upload.ts
│ │ └── parser.ts
│ └── README.md
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/openapi-mcp-server/mcp/proxy.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2 | import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
3 | import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
4 | import { JSONSchema7 as IJsonSchema } from 'json-schema'
5 | import { OpenAPIV3 } from 'openapi-types'
6 | import { HttpClient, HttpClientError } from '../client/http-client'
7 | import { OpenAPIToMCPConverter } from '../openapi/parser'
8 |
9 | type PathItemObject = OpenAPIV3.PathItemObject & {
10 | get?: OpenAPIV3.OperationObject
11 | put?: OpenAPIV3.OperationObject
12 | post?: OpenAPIV3.OperationObject
13 | delete?: OpenAPIV3.OperationObject
14 | patch?: OpenAPIV3.OperationObject
15 | }
16 |
17 | type NewToolDefinition = {
18 | methods: Array<{
19 | name: string
20 | description: string
21 | inputSchema: IJsonSchema & { type: 'object' }
22 | returnSchema?: IJsonSchema
23 | }>
24 | }
25 |
26 | // Notion object type definition
27 | interface NotionBlock {
28 | object: 'block';
29 | id: string;
30 | type: string;
31 | has_children?: boolean;
32 | [key: string]: any; // Allow additional fields
33 | }
34 |
35 | interface NotionPage {
36 | object: 'page';
37 | id: string;
38 | properties: Record<string, any>;
39 | [key: string]: any; // Allow additional fields
40 | }
41 |
42 | interface NotionDatabase {
43 | object: 'database';
44 | id: string;
45 | properties: Record<string, any>;
46 | [key: string]: any; // Allow additional fields
47 | }
48 |
49 | interface NotionComment {
50 | object: 'comment';
51 | id: string;
52 | [key: string]: any; // Allow additional fields
53 | }
54 |
55 | type NotionObject = NotionBlock | NotionPage | NotionDatabase | NotionComment;
56 |
57 | // Recursive exploration options
58 | interface RecursiveExplorationOptions {
59 | maxDepth?: number;
60 | includeDatabases?: boolean;
61 | includeComments?: boolean;
62 | includeProperties?: boolean;
63 | maxParallelRequests?: number;
64 | skipCache?: boolean;
65 | batchSize?: number;
66 | timeoutMs?: number;
67 | runInBackground?: boolean;
68 | }
69 |
70 | // import this class, extend and return server
71 | export class MCPProxy {
72 | private server: Server
73 | private httpClient: HttpClient
74 | private tools: Record<string, NewToolDefinition>
75 | private openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
76 | private pageCache: Map<string, any> = new Map() // Cache for performance improvement
77 | private blockCache: Map<string, any> = new Map() // Block cache
78 | private databaseCache: Map<string, any> = new Map() // Database cache
79 | private commentCache: Map<string, any> = new Map() // Comment cache
80 | private propertyCache: Map<string, any> = new Map() // Property cache
81 | private backgroundProcessingResults: Map<string, any> = new Map()
82 |
83 | constructor(name: string, openApiSpec: OpenAPIV3.Document) {
84 | this.server = new Server({ name, version: '1.0.0' }, { capabilities: { tools: {} } })
85 | const baseUrl = openApiSpec.servers?.[0].url
86 | if (!baseUrl) {
87 | throw new Error('No base URL found in OpenAPI spec')
88 | }
89 | this.httpClient = new HttpClient(
90 | {
91 | baseUrl,
92 | headers: this.parseHeadersFromEnv(),
93 | },
94 | openApiSpec,
95 | )
96 |
97 | // Convert OpenAPI spec to MCP tools
98 | const converter = new OpenAPIToMCPConverter(openApiSpec)
99 | const { tools, openApiLookup } = converter.convertToMCPTools()
100 | this.tools = tools
101 | this.openApiLookup = openApiLookup
102 |
103 | this.setupHandlers()
104 | }
105 |
106 | private setupHandlers() {
107 | // Handle tool listing
108 | this.server.setRequestHandler(ListToolsRequestSchema, async () => {
109 | const tools: Tool[] = []
110 |
111 | // Log available tools
112 | console.log('One Pager Assistant - Available tools:')
113 |
114 | // Add methods as separate tools to match the MCP format
115 | Object.entries(this.tools).forEach(([toolName, def]) => {
116 | def.methods.forEach(method => {
117 | const toolNameWithMethod = `${toolName}-${method.name}`;
118 | const truncatedToolName = this.truncateToolName(toolNameWithMethod);
119 | tools.push({
120 | name: truncatedToolName,
121 | description: method.description,
122 | inputSchema: method.inputSchema as Tool['inputSchema'],
123 | })
124 | console.log(`- ${truncatedToolName}: ${method.description}`)
125 | })
126 | })
127 |
128 | // Add extended One Pager tool
129 | const onePagerTool = {
130 | name: 'API-get-one-pager',
131 | description: 'Recursively retrieve a full Notion page with all its blocks, databases, and related content',
132 | inputSchema: {
133 | type: 'object',
134 | properties: {
135 | page_id: {
136 | type: 'string',
137 | description: 'Identifier for a Notion page',
138 | },
139 | maxDepth: {
140 | type: 'integer',
141 | description: 'Maximum recursion depth (default: 5)',
142 | },
143 | includeDatabases: {
144 | type: 'boolean',
145 | description: 'Whether to include linked databases (default: true)',
146 | },
147 | includeComments: {
148 | type: 'boolean',
149 | description: 'Whether to include comments (default: true)',
150 | },
151 | includeProperties: {
152 | type: 'boolean',
153 | description: 'Whether to include detailed page properties (default: true)',
154 | },
155 | maxParallelRequests: {
156 | type: 'integer',
157 | description: 'Maximum number of parallel requests (default: 15)',
158 | },
159 | batchSize: {
160 | type: 'integer',
161 | description: 'Batch size for parallel processing (default: 10)',
162 | },
163 | timeoutMs: {
164 | type: 'integer',
165 | description: 'Timeout in milliseconds (default: 300000)',
166 | },
167 | runInBackground: {
168 | type: 'boolean',
169 | description: 'Process request in background without timeout (default: true)',
170 | }
171 | },
172 | required: ['page_id'],
173 | } as Tool['inputSchema'],
174 | };
175 |
176 | tools.push(onePagerTool);
177 | console.log(`- ${onePagerTool.name}: ${onePagerTool.description}`);
178 |
179 | // Add tool to retrieve background processing results
180 | const backgroundResultTool = {
181 | name: 'API-get-background-result',
182 | description: 'Retrieve the result of a background processing request',
183 | inputSchema: {
184 | type: 'object',
185 | properties: {
186 | page_id: {
187 | type: 'string',
188 | description: 'Identifier for the Notion page that was processed in background',
189 | },
190 | },
191 | required: ['page_id'],
192 | } as Tool['inputSchema'],
193 | };
194 |
195 | tools.push(backgroundResultTool);
196 | console.log(`- ${backgroundResultTool.name}: ${backgroundResultTool.description}`);
197 |
198 | return { tools }
199 | })
200 |
201 | // Handle tool calling
202 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
203 | const { name, arguments: params } = request.params
204 |
205 | console.log(`One Pager Assistant - Tool call: ${name}`)
206 | console.log('Parameters:', JSON.stringify(params, null, 2))
207 |
208 | try {
209 | // Handle extended One Pager tool
210 | if (name === 'API-get-one-pager') {
211 | return await this.handleOnePagerRequest(params);
212 | }
213 |
214 | // Handle background result retrieval
215 | if (name === 'API-get-background-result') {
216 | const result = this.getBackgroundProcessingResult(params?.page_id as string);
217 | return {
218 | content: [
219 | {
220 | type: 'text',
221 | text: JSON.stringify(result),
222 | },
223 | ],
224 | };
225 | }
226 |
227 | // Find the operation in OpenAPI spec
228 | const operation = this.findOperation(name)
229 | if (!operation) {
230 | const error = `Method ${name} not found.`
231 | console.error(error)
232 | return {
233 | content: [
234 | {
235 | type: 'text',
236 | text: JSON.stringify({
237 | status: 'error',
238 | message: error,
239 | code: 404
240 | }),
241 | },
242 | ],
243 | }
244 | }
245 |
246 | // Optimized parallel processing for API-get-block-children
247 | if (name === 'API-get-block-children') {
248 | // Create basic options for logging control
249 | const blockOptions: RecursiveExplorationOptions = {
250 | runInBackground: false, // Default to not showing logs for regular API calls
251 | };
252 |
253 | return await this.handleBlockChildrenParallel(operation, params, blockOptions);
254 | }
255 |
256 | // Other regular API calls
257 | console.log(`Notion API call: ${operation.method.toUpperCase()} ${operation.path}`)
258 | const response = await this.httpClient.executeOperation(operation, params)
259 |
260 | // Log response summary
261 | console.log('Notion API response code:', response.status)
262 | if (response.status !== 200) {
263 | console.error('Response error:', response.data)
264 | } else {
265 | console.log('Response success')
266 | }
267 |
268 | // Update cache with response data
269 | this.updateCacheFromResponse(name, response.data);
270 |
271 | // Convert response to MCP format
272 | return {
273 | content: [
274 | {
275 | type: 'text',
276 | text: JSON.stringify(response.data),
277 | },
278 | ],
279 | }
280 | } catch (error) {
281 | console.error('Tool call error', error)
282 |
283 | if (error instanceof HttpClientError) {
284 | console.error('HttpClientError occurred, returning structured error', error)
285 | const data = error.data?.response?.data ?? error.data ?? {}
286 | return {
287 | content: [
288 | {
289 | type: 'text',
290 | text: JSON.stringify({
291 | status: 'error',
292 | code: error.status,
293 | message: error.message,
294 | details: typeof data === 'object' ? data : { data: data },
295 | }),
296 | },
297 | ],
298 | }
299 | }
300 |
301 | // Ensure any other errors are also properly formatted as JSON
302 | return {
303 | content: [
304 | {
305 | type: 'text',
306 | text: JSON.stringify({
307 | status: 'error',
308 | message: error instanceof Error ? error.message : String(error),
309 | code: 500
310 | }),
311 | },
312 | ],
313 | }
314 | }
315 | })
316 | }
317 |
318 | // Update cache based on API response type
319 | private updateCacheFromResponse(apiName: string, data: any): void {
320 | if (!data || typeof data !== 'object') return;
321 |
322 | try {
323 | // Update appropriate cache based on API response type
324 | if (apiName === 'API-retrieve-a-page' && data.object === 'page' && data.id) {
325 | this.pageCache.set(data.id, data);
326 | } else if (apiName === 'API-retrieve-a-block' && data.object === 'block' && data.id) {
327 | this.blockCache.set(data.id, data);
328 | } else if (apiName === 'API-retrieve-a-database' && data.object === 'database' && data.id) {
329 | this.databaseCache.set(data.id, data);
330 | } else if (apiName === 'API-retrieve-a-comment' && data.results) {
331 | // Cache comments from result list
332 | data.results.forEach((comment: any) => {
333 | if (comment.object === 'comment' && comment.id) {
334 | this.commentCache.set(comment.id, comment);
335 | }
336 | });
337 | } else if (apiName === 'API-retrieve-a-page-property' && data.results) {
338 | // Page property caching - would need params from call context
339 | // Skip this in current context
340 | console.log('Page property information has been cached');
341 | }
342 |
343 | // API-get-block-children handled in handleBlockChildrenParallel
344 | } catch (error) {
345 | console.warn('Error updating cache:', error);
346 | }
347 | }
348 |
349 | // One Pager request handler
350 | private async handleOnePagerRequest(params: any) {
351 | if (params.runInBackground !== false) {
352 | console.log('Starting One Pager request processing:', params.page_id);
353 | }
354 |
355 | const options: RecursiveExplorationOptions = {
356 | maxDepth: params.maxDepth || 5,
357 | includeDatabases: params.includeDatabases !== false,
358 | includeComments: params.includeComments !== false,
359 | includeProperties: params.includeProperties !== false,
360 | maxParallelRequests: params.maxParallelRequests || 15,
361 | skipCache: params.skipCache || false,
362 | batchSize: params.batchSize || 10,
363 | timeoutMs: params.timeoutMs || 300000, // Increased timeout to 5 minutes (300000ms)
364 | runInBackground: params.runInBackground !== false,
365 | };
366 |
367 | if (options.runInBackground) {
368 | console.log('Exploration options:', JSON.stringify(options, null, 2));
369 | }
370 |
371 | try {
372 | const startTime = Date.now();
373 |
374 | // Check if we should run in background mode
375 | if (options.runInBackground) {
376 | // Return immediately with a background processing message
377 | // The actual processing will continue in the background
378 | this.runBackgroundProcessing(params.page_id, options);
379 |
380 | return {
381 | content: [
382 | {
383 | type: 'text',
384 | text: JSON.stringify({
385 | status: 'processing',
386 | message: `Request processing for page ${params.page_id} started in background`,
387 | page_id: params.page_id,
388 | request_time: new Date().toISOString(),
389 | options: {
390 | maxDepth: options.maxDepth,
391 | includeDatabases: options.includeDatabases,
392 | includeComments: options.includeComments,
393 | includeProperties: options.includeProperties,
394 | timeoutMs: options.timeoutMs
395 | }
396 | }),
397 | },
398 | ],
399 | };
400 | }
401 |
402 | // Foreground processing (standard behavior)
403 | const pageData = await this.retrievePageRecursively(params.page_id, options);
404 |
405 | const duration = Date.now() - startTime;
406 | if (options.runInBackground) {
407 | console.log(`One Pager completed in ${duration}ms for page ${params.page_id}`);
408 | }
409 |
410 | return {
411 | content: [
412 | {
413 | type: 'text',
414 | text: JSON.stringify({
415 | ...pageData,
416 | _meta: {
417 | processingTimeMs: duration,
418 | retrievedAt: new Date().toISOString(),
419 | options: {
420 | maxDepth: options.maxDepth,
421 | includeDatabases: options.includeDatabases,
422 | includeComments: options.includeComments,
423 | includeProperties: options.includeProperties
424 | }
425 | }
426 | }),
427 | },
428 | ],
429 | };
430 | } catch (error) {
431 | if (options.runInBackground) {
432 | console.error('Error in One Pager request:', error);
433 | }
434 | const errorResponse = {
435 | status: 'error',
436 | message: error instanceof Error ? error.message : String(error),
437 | code: error instanceof HttpClientError ? error.status : 500,
438 | details: error instanceof HttpClientError ? error.data : undefined,
439 | timestamp: new Date().toISOString()
440 | };
441 |
442 | return {
443 | content: [
444 | {
445 | type: 'text',
446 | text: JSON.stringify(errorResponse),
447 | },
448 | ],
449 | };
450 | }
451 | }
452 |
453 | // New method to run processing in background
454 | private runBackgroundProcessing(pageId: string, options: RecursiveExplorationOptions): void {
455 | // Use setTimeout to detach from the current execution context
456 | setTimeout(async () => {
457 | try {
458 | console.log(`Background processing started for page ${pageId}`);
459 | const startTime = Date.now();
460 |
461 | // Execute the recursive page retrieval without time restrictions
462 | const noTimeoutOptions = { ...options, timeoutMs: 0 }; // 0 means no timeout
463 | const pageData = await this.retrievePageRecursively(pageId, noTimeoutOptions);
464 |
465 | const duration = Date.now() - startTime;
466 | console.log(`Background processing completed in ${duration}ms for page ${pageId}`);
467 |
468 | // Store the result in cache for later retrieval
469 | this.storeBackgroundProcessingResult(pageId, {
470 | ...pageData,
471 | _meta: {
472 | processingTimeMs: duration,
473 | retrievedAt: new Date().toISOString(),
474 | processedInBackground: true,
475 | options: {
476 | maxDepth: options.maxDepth,
477 | includeDatabases: options.includeDatabases,
478 | includeComments: options.includeComments,
479 | includeProperties: options.includeProperties
480 | }
481 | }
482 | });
483 | } catch (error) {
484 | console.error(`Background processing error for page ${pageId}:`, error);
485 | // Store error result for later retrieval
486 | this.storeBackgroundProcessingResult(pageId, {
487 | status: 'error',
488 | page_id: pageId,
489 | message: error instanceof Error ? error.message : String(error),
490 | timestamp: new Date().toISOString()
491 | });
492 | }
493 | }, 0);
494 | }
495 |
496 | // Store background processing results
497 | private storeBackgroundProcessingResult(pageId: string, result: any): void {
498 | this.backgroundProcessingResults.set(pageId, result);
499 | }
500 |
501 | // Add a new tool method to retrieve background processing results
502 | public getBackgroundProcessingResult(pageId: string): any {
503 | return this.backgroundProcessingResults.get(pageId) || {
504 | status: 'not_found',
505 | message: `No background processing result found for page ${pageId}`
506 | };
507 | }
508 |
509 | // Recursively retrieve page content
510 | private async retrievePageRecursively(pageId: string, options: RecursiveExplorationOptions, currentDepth: number = 0): Promise<any> {
511 | if (options.runInBackground) {
512 | console.log(`Recursive page exploration: ${pageId}, depth: ${currentDepth}/${options.maxDepth || 5}`);
513 | }
514 |
515 | const timeoutPromise = new Promise<never>((_, reject) => {
516 | if (options.timeoutMs && options.timeoutMs > 0) {
517 | setTimeout(() => reject(new Error(`Operation timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
518 | }
519 | });
520 |
521 | try {
522 | // Check maximum depth
523 | if (currentDepth >= (options.maxDepth || 5)) {
524 | if (options.runInBackground) {
525 | console.log(`Maximum depth reached: ${currentDepth}/${options.maxDepth || 5}`);
526 | }
527 | return { id: pageId, note: "Maximum recursion depth reached" };
528 | }
529 |
530 | // 1. Get basic page info (check cache)
531 | let pageData: any;
532 | if (!options.skipCache && this.pageCache.has(pageId)) {
533 | pageData = this.pageCache.get(pageId);
534 | if (options.runInBackground) {
535 | console.log(`Page cache hit: ${pageId}`);
536 | }
537 | } else {
538 | // Retrieve page info via API call
539 | const operation = this.findOperation('API-retrieve-a-page');
540 | if (!operation) {
541 | throw new Error('API-retrieve-a-page method not found.');
542 | }
543 |
544 | if (options.runInBackground) {
545 | console.log(`Notion API call: ${operation.method.toUpperCase()} ${operation.path} (pageId: ${pageId})`);
546 | }
547 |
548 | // Only race with timeout if timeoutMs is set
549 | let response;
550 | if (options.timeoutMs && options.timeoutMs > 0) {
551 | response = await Promise.race([
552 | this.httpClient.executeOperation(operation, { page_id: pageId }),
553 | timeoutPromise
554 | ]) as any;
555 | } else {
556 | response = await this.httpClient.executeOperation(operation, { page_id: pageId });
557 | }
558 |
559 | if (response.status !== 200) {
560 | if (options.runInBackground) {
561 | console.error('Error retrieving page information:', response.data);
562 | }
563 | return {
564 | id: pageId,
565 | error: "Failed to retrieve page",
566 | status: response.status,
567 | details: response.data
568 | };
569 | }
570 |
571 | pageData = response.data;
572 | // Only cache successful responses
573 | this.pageCache.set(pageId, pageData);
574 | }
575 |
576 | // Collection of tasks to be executed in parallel for improved efficiency
577 | const parallelTasks: Promise<any>[] = [];
578 |
579 | // 2. Fetch block content (register async task)
580 | const blocksPromise = this.retrieveBlocksRecursively(pageId, options, currentDepth + 1);
581 | parallelTasks.push(blocksPromise);
582 |
583 | // 3. Fetch property details (if option enabled)
584 | let propertiesPromise: Promise<any> = Promise.resolve(null);
585 | if (options.includeProperties && pageData.properties) {
586 | propertiesPromise = this.enrichPageProperties(pageId, pageData.properties, options);
587 | parallelTasks.push(propertiesPromise);
588 | }
589 |
590 | // 4. Fetch comments (if option enabled)
591 | let commentsPromise: Promise<any> = Promise.resolve(null);
592 | if (options.includeComments) {
593 | commentsPromise = this.retrieveComments(pageId, options);
594 | parallelTasks.push(commentsPromise);
595 | }
596 |
597 | // Execute all tasks in parallel
598 | if (options.timeoutMs && options.timeoutMs > 0) {
599 | await Promise.race([Promise.all(parallelTasks), timeoutPromise]);
600 | } else {
601 | await Promise.all(parallelTasks);
602 | }
603 |
604 | // Integrate results into the main page data
605 | const enrichedPageData = { ...pageData };
606 |
607 | // Add block content
608 | const blocksData = await blocksPromise;
609 | enrichedPageData.content = blocksData;
610 |
611 | // Add property details (if option enabled)
612 | if (options.includeProperties && pageData.properties) {
613 | const enrichedProperties = await propertiesPromise;
614 | if (enrichedProperties) {
615 | enrichedPageData.detailed_properties = enrichedProperties;
616 | }
617 | }
618 |
619 | // Add comments (if option enabled)
620 | if (options.includeComments) {
621 | const comments = await commentsPromise;
622 | if (comments && comments.results && comments.results.length > 0) {
623 | enrichedPageData.comments = comments;
624 | }
625 | }
626 |
627 | return enrichedPageData;
628 | } catch (error) {
629 | if (error instanceof Error && error.message.includes('timed out')) {
630 | if (options.runInBackground) {
631 | console.error(`Timeout occurred while processing page ${pageId} at depth ${currentDepth}`);
632 | }
633 | return {
634 | id: pageId,
635 | error: "Operation timed out",
636 | partial_results: true,
637 | note: `Processing exceeded timeout limit (${options.timeoutMs}ms)`
638 | };
639 | }
640 |
641 | if (options.runInBackground) {
642 | console.error(`Error in retrievePageRecursively for page ${pageId}:`, error);
643 | }
644 | return {
645 | id: pageId,
646 | error: error instanceof Error ? error.message : String(error),
647 | retrievalFailed: true
648 | };
649 | }
650 | }
651 |
652 | // Recursively retrieve block content with improved parallelism
653 | private async retrieveBlocksRecursively(blockId: string, options: RecursiveExplorationOptions, currentDepth: number): Promise<any[]> {
654 | if (options.runInBackground) {
655 | console.log(`Recursive block exploration: ${blockId}, depth: ${currentDepth}/${options.maxDepth || 5}`);
656 | }
657 |
658 | if (currentDepth >= (options.maxDepth || 5)) {
659 | if (options.runInBackground) {
660 | console.log(`Maximum depth reached: ${currentDepth}/${options.maxDepth || 5}`);
661 | }
662 | return [{ note: "Maximum recursion depth reached" }];
663 | }
664 |
665 | try {
666 | const operation = this.findOperation('API-get-block-children');
667 | if (!operation) {
668 | throw new Error('API-get-block-children method not found.');
669 | }
670 |
671 | const blocksResponse = await this.handleBlockChildrenParallel(operation, {
672 | block_id: blockId,
673 | page_size: 100
674 | }, options);
675 |
676 | const blocksData = JSON.parse(blocksResponse.content[0].text);
677 | const blocks = blocksData.results || [];
678 |
679 | if (blocks.length === 0) {
680 | return [];
681 | }
682 |
683 | const batchSize = options.batchSize || 10;
684 | const enrichedBlocks: any[] = [];
685 |
686 | // Process blocks in batches for memory optimization and improved parallel execution
687 | for (let i = 0; i < blocks.length; i += batchSize) {
688 | const batch = blocks.slice(i, i + batchSize);
689 |
690 | // Process each batch in parallel
691 | const batchResults = await Promise.all(
692 | batch.map(async (block: any) => {
693 | this.blockCache.set(block.id, block);
694 |
695 | const enrichedBlock = { ...block };
696 |
697 | // Collection of async tasks for this block
698 | const blockTasks: Promise<any>[] = [];
699 |
700 | // Process child blocks recursively
701 | if (block.has_children) {
702 | blockTasks.push(
703 | this.retrieveBlocksRecursively(block.id, options, currentDepth + 1)
704 | .then(childBlocks => { enrichedBlock.children = childBlocks; })
705 | .catch(error => {
706 | console.error(`Error retrieving child blocks for ${block.id}:`, error);
707 | enrichedBlock.children_error = { message: String(error) };
708 | return [];
709 | })
710 | );
711 | }
712 |
713 | // Process database blocks (if option enabled)
714 | if (options.includeDatabases &&
715 | (block.type === 'child_database' || block.type === 'linked_database')) {
716 | const databaseId = block[block.type]?.database_id;
717 | if (databaseId) {
718 | blockTasks.push(
719 | this.retrieveDatabase(databaseId, options)
720 | .then(database => { enrichedBlock.database = database; })
721 | .catch(error => {
722 | console.error(`Error retrieving database ${databaseId}:`, error);
723 | enrichedBlock.database_error = { message: String(error) };
724 | })
725 | );
726 | }
727 | }
728 |
729 | // Process page blocks or linked pages - optimization
730 | if (block.type === 'child_page' && currentDepth < (options.maxDepth || 5) - 1) {
731 | const pageId = block.id;
732 | blockTasks.push(
733 | this.retrievePageBasicInfo(pageId, options)
734 | .then(pageInfo => { enrichedBlock.page_info = pageInfo; })
735 | .catch(error => {
736 | console.error(`Error retrieving page info for ${pageId}:`, error);
737 | enrichedBlock.page_info_error = { message: String(error) };
738 | })
739 | );
740 | }
741 |
742 | // Wait for all async tasks to complete
743 | if (blockTasks.length > 0) {
744 | await Promise.all(blockTasks);
745 | }
746 |
747 | return enrichedBlock;
748 | })
749 | );
750 |
751 | enrichedBlocks.push(...batchResults);
752 | }
753 |
754 | return enrichedBlocks;
755 | } catch (error) {
756 | console.error(`Error in retrieveBlocksRecursively for block ${blockId}:`, error);
757 | return [{
758 | id: blockId,
759 | error: error instanceof Error ? error.message : String(error),
760 | retrievalFailed: true
761 | }];
762 | }
763 | }
764 |
765 | // Lightweight method to fetch only basic page info (without recursive loading)
766 | private async retrievePageBasicInfo(pageId: string, options: RecursiveExplorationOptions): Promise<any> {
767 | // Check cache
768 | if (!options.skipCache && this.pageCache.has(pageId)) {
769 | const cachedData = this.pageCache.get(pageId);
770 | return {
771 | id: cachedData.id,
772 | title: cachedData.properties?.title || { text: null },
773 | icon: cachedData.icon,
774 | cover: cachedData.cover,
775 | url: cachedData.url,
776 | fromCache: true
777 | };
778 | }
779 |
780 | // Get page info via API
781 | const operation = this.findOperation('API-retrieve-a-page');
782 | if (!operation) {
783 | return { id: pageId, note: "API-retrieve-a-page method not found" };
784 | }
785 |
786 | try {
787 | const response = await this.httpClient.executeOperation(operation, { page_id: pageId });
788 |
789 | if (response.status !== 200) {
790 | return { id: pageId, error: "Failed to retrieve page", status: response.status };
791 | }
792 |
793 | const pageData = response.data;
794 | this.pageCache.set(pageId, pageData);
795 |
796 | return {
797 | id: pageData.id,
798 | title: pageData.properties?.title || { text: null },
799 | icon: pageData.icon,
800 | cover: pageData.cover,
801 | url: pageData.url,
802 | created_time: pageData.created_time,
803 | last_edited_time: pageData.last_edited_time
804 | };
805 | } catch (error) {
806 | console.error(`Error retrieving basic page info ${pageId}:`, error);
807 | return { id: pageId, error: error instanceof Error ? error.message : String(error) };
808 | }
809 | }
810 |
811 | // Retrieve database information
812 | private async retrieveDatabase(databaseId: string, options: RecursiveExplorationOptions): Promise<any> {
813 | console.log(`Retrieving database information: ${databaseId}`);
814 |
815 | // Check cache
816 | if (!options.skipCache && this.databaseCache.has(databaseId)) {
817 | console.log(`Database cache hit: ${databaseId}`);
818 | return this.databaseCache.get(databaseId);
819 | }
820 |
821 | // Get database info via API call
822 | const operation = this.findOperation('API-retrieve-a-database');
823 | if (!operation) {
824 | console.warn('API-retrieve-a-database method not found.');
825 | return { id: databaseId, note: "Database details not available" };
826 | }
827 |
828 | try {
829 | console.log(`Notion API call: ${operation.method.toUpperCase()} ${operation.path} (databaseId: ${databaseId})`);
830 | const response = await this.httpClient.executeOperation(operation, { database_id: databaseId });
831 |
832 | if (response.status !== 200) {
833 | console.error('Error retrieving database information:', response.data);
834 | return { id: databaseId, error: "Failed to retrieve database" };
835 | }
836 |
837 | const databaseData = response.data;
838 | this.databaseCache.set(databaseId, databaseData);
839 | return databaseData;
840 | } catch (error) {
841 | console.error('Error retrieving database:', error);
842 | return { id: databaseId, error: "Failed to retrieve database" };
843 | }
844 | }
845 |
846 | // Retrieve comments
847 | private async retrieveComments(blockId: string, options: RecursiveExplorationOptions): Promise<any> {
848 | if (options.runInBackground) {
849 | console.log(`Retrieving comments: ${blockId}`);
850 | }
851 |
852 | // Get comments via API call
853 | const operation = this.findOperation('API-retrieve-a-comment');
854 | if (!operation) {
855 | if (options.runInBackground) {
856 | console.warn('API-retrieve-a-comment method not found.');
857 | }
858 | return Promise.resolve({ results: [] });
859 | }
860 |
861 | try {
862 | if (options.runInBackground) {
863 | console.log(`Notion API call: ${operation.method.toUpperCase()} ${operation.path} (blockId: ${blockId})`);
864 | }
865 |
866 | return this.httpClient.executeOperation(operation, { block_id: blockId })
867 | .then(response => {
868 | if (response.status !== 200) {
869 | if (options.runInBackground) {
870 | console.error('Error retrieving comments:', response.data);
871 | }
872 | return { results: [] };
873 | }
874 |
875 | const commentsData = response.data;
876 |
877 | // Cache comments
878 | if (commentsData.results) {
879 | commentsData.results.forEach((comment: any) => {
880 | if (comment.id) {
881 | this.commentCache.set(comment.id, comment);
882 | }
883 | });
884 | }
885 |
886 | return commentsData;
887 | })
888 | .catch(error => {
889 | if (options.runInBackground) {
890 | console.error('Error retrieving comments:', error);
891 | }
892 | return { results: [] };
893 | });
894 | } catch (error) {
895 | if (options.runInBackground) {
896 | console.error('Error retrieving comments:', error);
897 | }
898 | return Promise.resolve({ results: [] });
899 | }
900 | }
901 |
902 | // Enrich page properties with detailed information
903 | private async enrichPageProperties(pageId: string, properties: any, options: RecursiveExplorationOptions): Promise<any> {
904 | if (options.runInBackground) {
905 | console.log(`Enriching page properties: ${pageId}`);
906 | }
907 |
908 | const enrichedProperties = { ...properties };
909 | const propertyPromises: Promise<void>[] = [];
910 |
911 | // Get detailed information for each property
912 | for (const [propName, propData] of Object.entries(properties)) {
913 | const propId = (propData as any).id;
914 | if (!propId) continue;
915 |
916 | // Create cache key
917 | const cacheKey = `${pageId}:${propId}`;
918 |
919 | propertyPromises.push(
920 | (async () => {
921 | try {
922 | // Check cache
923 | if (!options.skipCache && this.propertyCache.has(cacheKey)) {
924 | enrichedProperties[propName].details = this.propertyCache.get(cacheKey);
925 | } else {
926 | // Skip properties with URLs that contain special characters like notion://
927 | if (propId.includes('notion://') || propId.includes('%3A%2F%2F')) {
928 | if (options.runInBackground) {
929 | console.warn(`Skipping property with special URL format: ${propName} (${propId})`);
930 | }
931 | enrichedProperties[propName].details = {
932 | object: 'property_item',
933 | type: 'unsupported',
934 | unsupported: { type: 'special_url_format' }
935 | };
936 | return;
937 | }
938 |
939 | // Get property details via API call
940 | const operation = this.findOperation('API-retrieve-a-page-property');
941 | if (!operation) {
942 | if (options.runInBackground) {
943 | console.warn('API-retrieve-a-page-property method not found.');
944 | }
945 | return;
946 | }
947 |
948 | const response = await this.httpClient.executeOperation(operation, {
949 | page_id: pageId,
950 | property_id: propId
951 | }).catch(error => {
952 | if (options.runInBackground) {
953 | console.warn(`Error retrieving property ${propName} (${propId}): ${error.message}`);
954 | }
955 | return {
956 | status: error.status || 500,
957 | data: {
958 | object: 'property_item',
959 | type: 'error',
960 | error: { message: error.message }
961 | }
962 | };
963 | });
964 |
965 | if (response.status === 200) {
966 | enrichedProperties[propName].details = response.data;
967 | this.propertyCache.set(cacheKey, response.data);
968 | } else {
969 | enrichedProperties[propName].details = {
970 | object: 'property_item',
971 | type: 'error',
972 | error: { status: response.status, message: JSON.stringify(response.data) }
973 | };
974 | }
975 | }
976 | } catch (error) {
977 | if (options.runInBackground) {
978 | console.error(`Error retrieving property ${propName}:`, error);
979 | }
980 | enrichedProperties[propName].details = {
981 | object: 'property_item',
982 | type: 'error',
983 | error: { message: error instanceof Error ? error.message : String(error) }
984 | };
985 | }
986 | })()
987 | );
988 | }
989 |
990 | // Get all property information in parallel
991 | await Promise.all(propertyPromises);
992 |
993 | return enrichedProperties;
994 | }
995 |
996 | // Optimized parallel processing for block children
997 | private async handleBlockChildrenParallel(
998 | operation: OpenAPIV3.OperationObject & { method: string; path: string },
999 | params: any,
1000 | options?: RecursiveExplorationOptions
1001 | ) {
1002 | if (options?.runInBackground) {
1003 | console.log(`Starting Notion API parallel processing: ${operation.method.toUpperCase()} ${operation.path}`);
1004 | }
1005 |
1006 | // Get first page
1007 | const initialResponse = await this.httpClient.executeOperation(operation, params);
1008 |
1009 | if (initialResponse.status !== 200) {
1010 | if (options?.runInBackground) {
1011 | console.error('Response error:', initialResponse.data);
1012 | }
1013 | return {
1014 | content: [{ type: 'text', text: JSON.stringify(initialResponse.data) }],
1015 | };
1016 | }
1017 |
1018 | const results = initialResponse.data.results || [];
1019 | let nextCursor = initialResponse.data.next_cursor;
1020 |
1021 | // Array for parallel processing
1022 | const pageRequests = [];
1023 | const maxParallelRequests = 5; // Limit simultaneous requests
1024 |
1025 | if (options?.runInBackground) {
1026 | console.log(`Retrieved ${results.length} blocks from first page`);
1027 | }
1028 |
1029 | // Request subsequent pages in parallel if available
1030 | while (nextCursor) {
1031 | // Clone parameters for next page
1032 | const nextPageParams = { ...params, start_cursor: nextCursor };
1033 |
1034 | // Add page request
1035 | pageRequests.push(
1036 | this.httpClient.executeOperation(operation, nextPageParams)
1037 | .then(response => {
1038 | if (response.status === 200) {
1039 | if (options?.runInBackground) {
1040 | console.log(`Retrieved ${response.data.results?.length || 0} blocks from additional page`);
1041 | }
1042 | return {
1043 | results: response.data.results || [],
1044 | next_cursor: response.data.next_cursor
1045 | };
1046 | }
1047 | return { results: [], next_cursor: null };
1048 | })
1049 | .catch(error => {
1050 | if (options?.runInBackground) {
1051 | console.error('Error retrieving page:', error);
1052 | }
1053 | return { results: [], next_cursor: null };
1054 | })
1055 | );
1056 |
1057 | // Execute parallel requests when batch size reached or no more pages
1058 | if (pageRequests.length >= maxParallelRequests || !nextCursor) {
1059 | if (options?.runInBackground) {
1060 | console.log(`Processing ${pageRequests.length} pages in parallel...`);
1061 | }
1062 | const pageResponses = await Promise.all(pageRequests);
1063 |
1064 | // Merge results
1065 | for (const response of pageResponses) {
1066 | results.push(...response.results);
1067 | // Set next cursor for next batch
1068 | if (response.next_cursor) {
1069 | nextCursor = response.next_cursor;
1070 | } else {
1071 | nextCursor = null;
1072 | }
1073 | }
1074 |
1075 | // Reset request array
1076 | pageRequests.length = 0;
1077 | }
1078 |
1079 | // Exit loop if no more pages
1080 | if (!nextCursor) break;
1081 | }
1082 |
1083 | if (options?.runInBackground) {
1084 | console.log(`Retrieved ${results.length} blocks in total`);
1085 | }
1086 |
1087 | // Return merged response
1088 | const mergedResponse = {
1089 | ...initialResponse.data,
1090 | results,
1091 | has_more: false,
1092 | next_cursor: null
1093 | };
1094 |
1095 | return {
1096 | content: [{ type: 'text', text: JSON.stringify(mergedResponse) }],
1097 | };
1098 | }
1099 |
1100 | private findOperation(operationId: string): (OpenAPIV3.OperationObject & { method: string; path: string }) | null {
1101 | return this.openApiLookup[operationId] ?? null
1102 | }
1103 |
1104 | private parseHeadersFromEnv(): Record<string, string> {
1105 | const headersJson = process.env.OPENAPI_MCP_HEADERS
1106 | if (!headersJson) {
1107 | return {}
1108 | }
1109 |
1110 | try {
1111 | const headers = JSON.parse(headersJson)
1112 | if (typeof headers !== 'object' || headers === null) {
1113 | console.warn('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', typeof headers)
1114 | return {}
1115 | }
1116 | return headers
1117 | } catch (error) {
1118 | console.warn('Failed to parse OPENAPI_MCP_HEADERS environment variable:', error)
1119 | return {}
1120 | }
1121 | }
1122 |
1123 | private getContentType(headers: Headers): 'text' | 'image' | 'binary' {
1124 | const contentType = headers.get('content-type')
1125 | if (!contentType) return 'binary'
1126 |
1127 | if (contentType.includes('text') || contentType.includes('json')) {
1128 | return 'text'
1129 | } else if (contentType.includes('image')) {
1130 | return 'image'
1131 | }
1132 | return 'binary'
1133 | }
1134 |
1135 | private truncateToolName(name: string): string {
1136 | if (name.length <= 64) {
1137 | return name;
1138 | }
1139 | return name.slice(0, 64);
1140 | }
1141 |
1142 | async connect(transport: Transport) {
1143 | console.log('One Pager Assistant - MCP server started')
1144 | console.log('Providing APIs: retrieve-a-page, get-block-children, retrieve-a-block')
1145 | console.log('New feature: get-one-pager - recursively explore pages automatically')
1146 | console.log('Parallel processing optimization enabled')
1147 |
1148 | // The SDK will handle stdio communication
1149 | await this.server.connect(transport)
1150 | }
1151 |
1152 | getServer() {
1153 | return this.server
1154 | }
1155 | }
1156 |
```
--------------------------------------------------------------------------------
/src/openapi-mcp-server/openapi/__tests__/parser.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { OpenAPIToMCPConverter } from '../parser'
2 | import { OpenAPIV3 } from 'openapi-types'
3 | import { describe, expect, it } from 'vitest'
4 | import { JSONSchema7 as IJsonSchema } from 'json-schema'
5 |
6 | interface ToolMethod {
7 | name: string
8 | description: string
9 | inputSchema: any
10 | returnSchema?: any
11 | }
12 |
13 | interface Tool {
14 | methods: ToolMethod[]
15 | }
16 |
17 | interface Tools {
18 | [key: string]: Tool
19 | }
20 |
21 | // Helper function to verify tool method structure without checking the exact Zod schema
22 | function verifyToolMethod(actual: ToolMethod, expected: any, toolName: string) {
23 | expect(actual.name).toBe(expected.name)
24 | expect(actual.description).toBe(expected.description)
25 | expect(actual.inputSchema, `inputSchema ${actual.name} ${toolName}`).toEqual(expected.inputSchema)
26 | if (expected.returnSchema) {
27 | expect(actual.returnSchema, `returnSchema ${actual.name} ${toolName}`).toEqual(expected.returnSchema)
28 | }
29 | }
30 |
31 | // Helper function to verify tools structure
32 | function verifyTools(actual: Tools, expected: any) {
33 | expect(Object.keys(actual)).toEqual(Object.keys(expected))
34 | for (const [key, value] of Object.entries(actual)) {
35 | expect(value.methods.length).toBe(expected[key].methods.length)
36 | value.methods.forEach((method: ToolMethod, index: number) => {
37 | verifyToolMethod(method, expected[key].methods[index], key)
38 | })
39 | }
40 | }
41 |
42 | // A helper function to derive a type from a possibly complex schema.
43 | // If no explicit type is found, we assume 'object' for testing purposes.
44 | function getTypeFromSchema(schema: IJsonSchema): string {
45 | if (schema.type) {
46 | return Array.isArray(schema.type) ? schema.type[0] : schema.type
47 | } else if (schema.$ref) {
48 | // If there's a $ref, we treat it as an object reference.
49 | return 'object'
50 | } else if (schema.oneOf || schema.anyOf || schema.allOf) {
51 | // Complex schema combos - assume object for these tests.
52 | return 'object'
53 | }
54 | return 'object'
55 | }
56 |
57 | // Updated helper function to get parameters from inputSchema
58 | // Now handles $ref by treating it as an object reference without expecting properties.
59 | function getParamsFromSchema(method: { inputSchema: IJsonSchema }) {
60 | return Object.entries(method.inputSchema.properties || {}).map(([name, prop]) => {
61 | if (typeof prop === 'boolean') {
62 | throw new Error(`Boolean schema not supported for parameter ${name}`)
63 | }
64 |
65 | // If there's a $ref, treat it as an object reference.
66 | const schemaType = getTypeFromSchema(prop)
67 | return {
68 | name,
69 | type: schemaType,
70 | description: prop.description,
71 | optional: !(method.inputSchema.required || []).includes(name),
72 | }
73 | })
74 | }
75 |
76 | // Updated helper function to get return type from returnSchema
77 | // No longer requires that the schema be fully expanded. If we have a $ref, just note it as 'object'.
78 | function getReturnType(method: { returnSchema?: IJsonSchema }) {
79 | if (!method.returnSchema) return null
80 | const schema = method.returnSchema
81 | return {
82 | type: getTypeFromSchema(schema),
83 | description: schema.description,
84 | }
85 | }
86 |
87 | describe('OpenAPIToMCPConverter', () => {
88 | describe('Simple API Conversion', () => {
89 | const sampleSpec: OpenAPIV3.Document = {
90 | openapi: '3.0.0',
91 | info: {
92 | title: 'Test API',
93 | version: '1.0.0',
94 | },
95 | paths: {
96 | '/pets/{petId}': {
97 | get: {
98 | operationId: 'getPet',
99 | summary: 'Get a pet by ID',
100 | parameters: [
101 | {
102 | name: 'petId',
103 | in: 'path',
104 | required: true,
105 | description: 'The ID of the pet',
106 | schema: {
107 | type: 'integer',
108 | },
109 | },
110 | ],
111 | responses: {
112 | '200': {
113 | description: 'Pet found',
114 | content: {
115 | 'application/json': {
116 | schema: {
117 | type: 'object',
118 | properties: {
119 | id: { type: 'integer' },
120 | name: { type: 'string' },
121 | },
122 | },
123 | },
124 | },
125 | },
126 | },
127 | },
128 | },
129 | },
130 | }
131 |
132 | it('converts simple OpenAPI paths to MCP tools', () => {
133 | const converter = new OpenAPIToMCPConverter(sampleSpec)
134 | const { tools, openApiLookup } = converter.convertToMCPTools()
135 |
136 | expect(tools).toHaveProperty('API')
137 | expect(tools.API.methods).toHaveLength(1)
138 | expect(Object.keys(openApiLookup)).toHaveLength(1)
139 |
140 | const getPetMethod = tools.API.methods.find((m) => m.name === 'getPet')
141 | expect(getPetMethod).toBeDefined()
142 |
143 | const params = getParamsFromSchema(getPetMethod!)
144 | expect(params).toContainEqual({
145 | name: 'petId',
146 | type: 'integer',
147 | description: 'The ID of the pet',
148 | optional: false,
149 | })
150 | })
151 |
152 | it('truncates tool names exceeding 64 characters', () => {
153 | const longOperationId = 'a'.repeat(65)
154 | const specWithLongName: OpenAPIV3.Document = {
155 | openapi: '3.0.0',
156 | info: {
157 | title: 'Test API',
158 | version: '1.0.0'
159 | },
160 | paths: {
161 | '/pets/{petId}': {
162 | get: {
163 | operationId: longOperationId,
164 | summary: 'Get a pet by ID',
165 | parameters: [
166 | {
167 | name: 'petId',
168 | in: 'path',
169 | required: true,
170 | description: 'The ID of the pet',
171 | schema: {
172 | type: 'integer'
173 | }
174 | }
175 | ],
176 | responses: {
177 | '200': {
178 | description: 'Pet found',
179 | content: {
180 | 'application/json': {
181 | schema: {
182 | type: 'object',
183 | properties: {
184 | id: { type: 'integer' },
185 | name: { type: 'string' }
186 | }
187 | }
188 | }
189 | }
190 | }
191 | }
192 | }
193 | }
194 | }
195 | }
196 |
197 | const converter = new OpenAPIToMCPConverter(specWithLongName)
198 | const { tools } = converter.convertToMCPTools()
199 |
200 | const longNameMethod = tools.API.methods.find(m => m.name.startsWith('a'.repeat(59)))
201 | expect(longNameMethod).toBeDefined()
202 | expect(longNameMethod!.name.length).toBeLessThanOrEqual(64)
203 | })
204 | })
205 |
206 | describe('Complex API Conversion', () => {
207 | const complexSpec: OpenAPIV3.Document = {
208 | openapi: '3.0.0',
209 | info: { title: 'Complex API', version: '1.0.0' },
210 | components: {
211 | schemas: {
212 | Error: {
213 | type: 'object',
214 | required: ['code', 'message'],
215 | properties: {
216 | code: { type: 'integer' },
217 | message: { type: 'string' },
218 | },
219 | },
220 | Pet: {
221 | type: 'object',
222 | required: ['id', 'name'],
223 | properties: {
224 | id: { type: 'integer', description: 'The ID of the pet' },
225 | name: { type: 'string', description: 'The name of the pet' },
226 | category: { $ref: '#/components/schemas/Category', description: 'The category of the pet' },
227 | tags: {
228 | type: 'array',
229 | description: 'The tags of the pet',
230 | items: { $ref: '#/components/schemas/Tag' },
231 | },
232 | status: {
233 | type: 'string',
234 | description: 'The status of the pet',
235 | enum: ['available', 'pending', 'sold'],
236 | },
237 | },
238 | },
239 | Category: {
240 | type: 'object',
241 | required: ['id', 'name'],
242 | properties: {
243 | id: { type: 'integer' },
244 | name: { type: 'string' },
245 | subcategories: {
246 | type: 'array',
247 | items: { $ref: '#/components/schemas/Category' },
248 | },
249 | },
250 | },
251 | Tag: {
252 | type: 'object',
253 | required: ['id', 'name'],
254 | properties: {
255 | id: { type: 'integer' },
256 | name: { type: 'string' },
257 | },
258 | },
259 | },
260 | parameters: {
261 | PetId: {
262 | name: 'petId',
263 | in: 'path',
264 | required: true,
265 | description: 'ID of pet to fetch',
266 | schema: { type: 'integer' },
267 | },
268 | QueryLimit: {
269 | name: 'limit',
270 | in: 'query',
271 | description: 'Maximum number of results to return',
272 | schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
273 | },
274 | },
275 | responses: {
276 | NotFound: {
277 | description: 'The specified resource was not found',
278 | content: {
279 | 'application/json': {
280 | schema: { $ref: '#/components/schemas/Error' },
281 | },
282 | },
283 | },
284 | },
285 | },
286 | paths: {
287 | '/pets': {
288 | get: {
289 | operationId: 'listPets',
290 | summary: 'List all pets',
291 | parameters: [{ $ref: '#/components/parameters/QueryLimit' }],
292 | responses: {
293 | '200': {
294 | description: 'A list of pets',
295 | content: {
296 | 'application/json': {
297 | schema: {
298 | type: 'array',
299 | items: { $ref: '#/components/schemas/Pet' },
300 | },
301 | },
302 | },
303 | },
304 | },
305 | },
306 | post: {
307 | operationId: 'createPet',
308 | summary: 'Create a pet',
309 | requestBody: {
310 | required: true,
311 | content: {
312 | 'application/json': {
313 | schema: { $ref: '#/components/schemas/Pet' },
314 | },
315 | },
316 | },
317 | responses: {
318 | '201': {
319 | description: 'Pet created',
320 | content: {
321 | 'application/json': {
322 | schema: { $ref: '#/components/schemas/Pet' },
323 | },
324 | },
325 | },
326 | },
327 | },
328 | },
329 | '/pets/{petId}': {
330 | get: {
331 | operationId: 'getPet',
332 | summary: 'Get a pet by ID',
333 | parameters: [{ $ref: '#/components/parameters/PetId' }],
334 | responses: {
335 | '200': {
336 | description: 'Pet found',
337 | content: {
338 | 'application/json': {
339 | schema: { $ref: '#/components/schemas/Pet' },
340 | },
341 | },
342 | },
343 | '404': {
344 | $ref: '#/components/responses/NotFound',
345 | },
346 | },
347 | },
348 | put: {
349 | operationId: 'updatePet',
350 | summary: 'Update a pet',
351 | parameters: [{ $ref: '#/components/parameters/PetId' }],
352 | requestBody: {
353 | required: true,
354 | content: {
355 | 'application/json': {
356 | schema: { $ref: '#/components/schemas/Pet' },
357 | },
358 | },
359 | },
360 | responses: {
361 | '200': {
362 | description: 'Pet updated',
363 | content: {
364 | 'application/json': {
365 | schema: { $ref: '#/components/schemas/Pet' },
366 | },
367 | },
368 | },
369 | '404': {
370 | $ref: '#/components/responses/NotFound',
371 | },
372 | },
373 | },
374 | },
375 | },
376 | }
377 |
378 | it('converts operations with referenced parameters', () => {
379 | const converter = new OpenAPIToMCPConverter(complexSpec)
380 | const { tools } = converter.convertToMCPTools()
381 |
382 | const getPetMethod = tools.API.methods.find((m) => m.name === 'getPet')
383 | expect(getPetMethod).toBeDefined()
384 | const params = getParamsFromSchema(getPetMethod!)
385 | expect(params).toContainEqual({
386 | name: 'petId',
387 | type: 'integer',
388 | description: 'ID of pet to fetch',
389 | optional: false,
390 | })
391 | })
392 |
393 | it('converts operations with query parameters', () => {
394 | const converter = new OpenAPIToMCPConverter(complexSpec)
395 | const { tools } = converter.convertToMCPTools()
396 |
397 | const listPetsMethod = tools.API.methods.find((m) => m.name === 'listPets')
398 | expect(listPetsMethod).toBeDefined()
399 |
400 | const params = getParamsFromSchema(listPetsMethod!)
401 | expect(params).toContainEqual({
402 | name: 'limit',
403 | type: 'integer',
404 | description: 'Maximum number of results to return',
405 | optional: true,
406 | })
407 | })
408 |
409 | it('converts operations with array responses', () => {
410 | const converter = new OpenAPIToMCPConverter(complexSpec)
411 | const { tools } = converter.convertToMCPTools()
412 |
413 | const listPetsMethod = tools.API.methods.find((m) => m.name === 'listPets')
414 | expect(listPetsMethod).toBeDefined()
415 |
416 | const returnType = getReturnType(listPetsMethod!)
417 | // Now we only check type since description might not be carried through
418 | // if we are not expanding schemas.
419 | expect(returnType).toMatchObject({
420 | type: 'array',
421 | })
422 | })
423 |
424 | it('converts operations with request bodies using $ref', () => {
425 | const converter = new OpenAPIToMCPConverter(complexSpec)
426 | const { tools } = converter.convertToMCPTools()
427 |
428 | const createPetMethod = tools.API.methods.find((m) => m.name === 'createPet')
429 | expect(createPetMethod).toBeDefined()
430 |
431 | const params = getParamsFromSchema(createPetMethod!)
432 | // Now that we are preserving $ref, the request body won't be expanded into multiple parameters.
433 | // Instead, we'll have a single "body" parameter referencing Pet.
434 | expect(params).toEqual(
435 | expect.arrayContaining([
436 | expect.objectContaining({
437 | name: 'body',
438 | type: 'object', // Because it's a $ref
439 | optional: false,
440 | }),
441 | ]),
442 | )
443 | })
444 |
445 | it('converts operations with referenced error responses', () => {
446 | const converter = new OpenAPIToMCPConverter(complexSpec)
447 | const { tools } = converter.convertToMCPTools()
448 |
449 | const getPetMethod = tools.API.methods.find((m) => m.name === 'getPet')
450 | expect(getPetMethod).toBeDefined()
451 |
452 | // We just check that the description includes the error references now.
453 | expect(getPetMethod?.description).toContain('404: The specified resource was not found')
454 | })
455 |
456 | it('handles recursive schema references without expanding them', () => {
457 | const converter = new OpenAPIToMCPConverter(complexSpec)
458 | const { tools } = converter.convertToMCPTools()
459 |
460 | const createPetMethod = tools.API.methods.find((m) => m.name === 'createPet')
461 | expect(createPetMethod).toBeDefined()
462 |
463 | const params = getParamsFromSchema(createPetMethod!)
464 | // Since "category" would be inside Pet, and we're not expanding,
465 | // we won't see 'category' directly. We only have 'body' as a reference.
466 | // Thus, the test no longer checks for a direct 'category' param.
467 | expect(params.find((p) => p.name === 'body')).toBeDefined()
468 | })
469 |
470 | it('converts all operations correctly respecting $ref usage', () => {
471 | const converter = new OpenAPIToMCPConverter(complexSpec)
472 | const { tools } = converter.convertToMCPTools()
473 |
474 | expect(tools.API.methods).toHaveLength(4)
475 |
476 | const methodNames = tools.API.methods.map((m) => m.name)
477 | expect(methodNames).toEqual(expect.arrayContaining(['listPets', 'createPet', 'getPet', 'updatePet']))
478 |
479 | tools.API.methods.forEach((method) => {
480 | expect(method).toHaveProperty('name')
481 | expect(method).toHaveProperty('description')
482 | expect(method).toHaveProperty('inputSchema')
483 | expect(method).toHaveProperty('returnSchema')
484 |
485 | // For 'get' operations, we just check the return type is recognized correctly.
486 | if (method.name.startsWith('get')) {
487 | const returnType = getReturnType(method)
488 | // With $ref usage, we can't guarantee description or direct expansion.
489 | expect(returnType?.type).toBe('object')
490 | }
491 | })
492 | })
493 | })
494 |
495 | describe('Complex Schema Conversion', () => {
496 | // A similar approach for the nested spec
497 | // Just as in the previous tests, we no longer test for direct property expansion.
498 | // We only confirm that parameters and return types are recognized and that references are preserved.
499 |
500 | const nestedSpec: OpenAPIV3.Document = {
501 | openapi: '3.0.0',
502 | info: { title: 'Nested API', version: '1.0.0' },
503 | components: {
504 | schemas: {
505 | Organization: {
506 | type: 'object',
507 | required: ['id', 'name'],
508 | properties: {
509 | id: { type: 'integer' },
510 | name: { type: 'string' },
511 | departments: {
512 | type: 'array',
513 | items: { $ref: '#/components/schemas/Department' },
514 | },
515 | metadata: { $ref: '#/components/schemas/Metadata' },
516 | },
517 | },
518 | Department: {
519 | type: 'object',
520 | required: ['id', 'name'],
521 | properties: {
522 | id: { type: 'integer' },
523 | name: { type: 'string' },
524 | employees: {
525 | type: 'array',
526 | items: { $ref: '#/components/schemas/Employee' },
527 | },
528 | subDepartments: {
529 | type: 'array',
530 | items: { $ref: '#/components/schemas/Department' },
531 | },
532 | metadata: { $ref: '#/components/schemas/Metadata' },
533 | },
534 | },
535 | Employee: {
536 | type: 'object',
537 | required: ['id', 'name'],
538 | properties: {
539 | id: { type: 'integer' },
540 | name: { type: 'string' },
541 | role: { $ref: '#/components/schemas/Role' },
542 | skills: {
543 | type: 'array',
544 | items: { $ref: '#/components/schemas/Skill' },
545 | },
546 | metadata: { $ref: '#/components/schemas/Metadata' },
547 | },
548 | },
549 | Role: {
550 | type: 'object',
551 | required: ['id', 'name'],
552 | properties: {
553 | id: { type: 'integer' },
554 | name: { type: 'string' },
555 | permissions: {
556 | type: 'array',
557 | items: { $ref: '#/components/schemas/Permission' },
558 | },
559 | },
560 | },
561 | Permission: {
562 | type: 'object',
563 | required: ['id', 'name'],
564 | properties: {
565 | id: { type: 'integer' },
566 | name: { type: 'string' },
567 | scope: { type: 'string' },
568 | },
569 | },
570 | Skill: {
571 | type: 'object',
572 | required: ['id', 'name'],
573 | properties: {
574 | id: { type: 'integer' },
575 | name: { type: 'string' },
576 | level: {
577 | type: 'string',
578 | enum: ['beginner', 'intermediate', 'expert'],
579 | },
580 | },
581 | },
582 | Metadata: {
583 | type: 'object',
584 | properties: {
585 | createdAt: { type: 'string', format: 'date-time' },
586 | updatedAt: { type: 'string', format: 'date-time' },
587 | tags: {
588 | type: 'array',
589 | items: { type: 'string' },
590 | },
591 | customFields: {
592 | type: 'object',
593 | additionalProperties: true,
594 | },
595 | },
596 | },
597 | },
598 | parameters: {
599 | OrgId: {
600 | name: 'orgId',
601 | in: 'path',
602 | required: true,
603 | description: 'Organization ID',
604 | schema: { type: 'integer' },
605 | },
606 | DeptId: {
607 | name: 'deptId',
608 | in: 'path',
609 | required: true,
610 | description: 'Department ID',
611 | schema: { type: 'integer' },
612 | },
613 | IncludeMetadata: {
614 | name: 'includeMetadata',
615 | in: 'query',
616 | description: 'Include metadata in response',
617 | schema: { type: 'boolean', default: false },
618 | },
619 | Depth: {
620 | name: 'depth',
621 | in: 'query',
622 | description: 'Depth of nested objects to return',
623 | schema: { type: 'integer', minimum: 1, maximum: 5, default: 1 },
624 | },
625 | },
626 | },
627 | paths: {
628 | '/organizations/{orgId}': {
629 | get: {
630 | operationId: 'getOrganization',
631 | summary: 'Get organization details',
632 | parameters: [
633 | { $ref: '#/components/parameters/OrgId' },
634 | { $ref: '#/components/parameters/IncludeMetadata' },
635 | { $ref: '#/components/parameters/Depth' },
636 | ],
637 | responses: {
638 | '200': {
639 | description: 'Organization details',
640 | content: {
641 | 'application/json': {
642 | schema: { $ref: '#/components/schemas/Organization' },
643 | },
644 | },
645 | },
646 | },
647 | },
648 | },
649 | '/organizations/{orgId}/departments/{deptId}': {
650 | get: {
651 | operationId: 'getDepartment',
652 | summary: 'Get department details',
653 | parameters: [
654 | { $ref: '#/components/parameters/OrgId' },
655 | { $ref: '#/components/parameters/DeptId' },
656 | { $ref: '#/components/parameters/IncludeMetadata' },
657 | { $ref: '#/components/parameters/Depth' },
658 | ],
659 | responses: {
660 | '200': {
661 | description: 'Department details',
662 | content: {
663 | 'application/json': {
664 | schema: { $ref: '#/components/schemas/Department' },
665 | },
666 | },
667 | },
668 | },
669 | },
670 | put: {
671 | operationId: 'updateDepartment',
672 | summary: 'Update department details',
673 | parameters: [{ $ref: '#/components/parameters/OrgId' }, { $ref: '#/components/parameters/DeptId' }],
674 | requestBody: {
675 | required: true,
676 | content: {
677 | 'application/json': {
678 | schema: { $ref: '#/components/schemas/Department' },
679 | },
680 | },
681 | },
682 | responses: {
683 | '200': {
684 | description: 'Department updated',
685 | content: {
686 | 'application/json': {
687 | schema: { $ref: '#/components/schemas/Department' },
688 | },
689 | },
690 | },
691 | },
692 | },
693 | },
694 | },
695 | }
696 |
697 | it('handles deeply nested object references', () => {
698 | const converter = new OpenAPIToMCPConverter(nestedSpec)
699 | const { tools } = converter.convertToMCPTools()
700 |
701 | const getOrgMethod = tools.API.methods.find((m) => m.name === 'getOrganization')
702 | expect(getOrgMethod).toBeDefined()
703 |
704 | const params = getParamsFromSchema(getOrgMethod!)
705 | expect(params).toEqual(
706 | expect.arrayContaining([
707 | expect.objectContaining({
708 | name: 'orgId',
709 | type: 'integer',
710 | description: 'Organization ID',
711 | optional: false,
712 | }),
713 | expect.objectContaining({
714 | name: 'includeMetadata',
715 | type: 'boolean',
716 | description: 'Include metadata in response',
717 | optional: true,
718 | }),
719 | expect.objectContaining({
720 | name: 'depth',
721 | type: 'integer',
722 | description: 'Depth of nested objects to return',
723 | optional: true,
724 | }),
725 | ]),
726 | )
727 | })
728 |
729 | it('handles recursive array references without requiring expansion', () => {
730 | const converter = new OpenAPIToMCPConverter(nestedSpec)
731 | const { tools } = converter.convertToMCPTools()
732 |
733 | const updateDeptMethod = tools.API.methods.find((m) => m.name === 'updateDepartment')
734 | expect(updateDeptMethod).toBeDefined()
735 |
736 | const params = getParamsFromSchema(updateDeptMethod!)
737 | // With $ref usage, we have a body parameter referencing Department.
738 | // The subDepartments array is inside Department, so we won't see it expanded here.
739 | // Instead, we just confirm 'body' is present.
740 | const bodyParam = params.find((p) => p.name === 'body')
741 | expect(bodyParam).toBeDefined()
742 | expect(bodyParam?.type).toBe('object')
743 | })
744 |
745 | it('handles complex nested object hierarchies without expansion', () => {
746 | const converter = new OpenAPIToMCPConverter(nestedSpec)
747 | const { tools } = converter.convertToMCPTools()
748 |
749 | const getDeptMethod = tools.API.methods.find((m) => m.name === 'getDepartment')
750 | expect(getDeptMethod).toBeDefined()
751 |
752 | const params = getParamsFromSchema(getDeptMethod!)
753 | // Just checking top-level params:
754 | expect(params).toEqual(
755 | expect.arrayContaining([
756 | expect.objectContaining({
757 | name: 'orgId',
758 | type: 'integer',
759 | optional: false,
760 | }),
761 | expect.objectContaining({
762 | name: 'deptId',
763 | type: 'integer',
764 | optional: false,
765 | }),
766 | expect.objectContaining({
767 | name: 'includeMetadata',
768 | type: 'boolean',
769 | optional: true,
770 | }),
771 | expect.objectContaining({
772 | name: 'depth',
773 | type: 'integer',
774 | optional: true,
775 | }),
776 | ]),
777 | )
778 | })
779 |
780 | it('handles schema with mixed primitive and reference types without expansion', () => {
781 | const converter = new OpenAPIToMCPConverter(nestedSpec)
782 | const { tools } = converter.convertToMCPTools()
783 |
784 | const updateDeptMethod = tools.API.methods.find((m) => m.name === 'updateDepartment')
785 | expect(updateDeptMethod).toBeDefined()
786 |
787 | const params = getParamsFromSchema(updateDeptMethod!)
788 | // Since we are not expanding, we won't see metadata fields directly.
789 | // We just confirm 'body' referencing Department is there.
790 | expect(params.find((p) => p.name === 'body')).toBeDefined()
791 | })
792 |
793 | it('converts all operations with complex schemas correctly respecting $ref', () => {
794 | const converter = new OpenAPIToMCPConverter(nestedSpec)
795 | const { tools } = converter.convertToMCPTools()
796 |
797 | expect(tools.API.methods).toHaveLength(3)
798 |
799 | const methodNames = tools.API.methods.map((m) => m.name)
800 | expect(methodNames).toEqual(expect.arrayContaining(['getOrganization', 'getDepartment', 'updateDepartment']))
801 |
802 | tools.API.methods.forEach((method) => {
803 | expect(method).toHaveProperty('name')
804 | expect(method).toHaveProperty('description')
805 | expect(method).toHaveProperty('inputSchema')
806 | expect(method).toHaveProperty('returnSchema')
807 |
808 | // If it's a GET operation, check that return type is recognized.
809 | if (method.name.startsWith('get')) {
810 | const returnType = getReturnType(method)
811 | // Without expansion, just check type is recognized as object.
812 | expect(returnType).toMatchObject({
813 | type: 'object',
814 | })
815 | }
816 | })
817 | })
818 | })
819 |
820 | it('preserves description on $ref nodes', () => {
821 | const spec: OpenAPIV3.Document = {
822 | openapi: '3.0.0',
823 | info: { title: 'Test API', version: '1.0.0' },
824 | paths: {},
825 | components: {
826 | schemas: {
827 | TestSchema: {
828 | type: 'object',
829 | properties: {
830 | name: { type: 'string' },
831 | },
832 | },
833 | },
834 | },
835 | }
836 |
837 | const converter = new OpenAPIToMCPConverter(spec)
838 | const result = converter.convertOpenApiSchemaToJsonSchema(
839 | {
840 | $ref: '#/components/schemas/TestSchema',
841 | description: 'A schema description',
842 | },
843 | new Set(),
844 | )
845 |
846 | expect(result).toEqual({
847 | $ref: '#/$defs/TestSchema',
848 | description: 'A schema description',
849 | })
850 | })
851 | })
852 |
853 | // Additional complex test scenarios as a table test
854 | describe('OpenAPIToMCPConverter - Additional Complex Tests', () => {
855 | interface TestCase {
856 | name: string
857 | input: OpenAPIV3.Document
858 | expected: {
859 | tools: Record<
860 | string,
861 | {
862 | methods: Array<{
863 | name: string
864 | description: string
865 | inputSchema: IJsonSchema & { type: 'object' }
866 | returnSchema?: IJsonSchema
867 | }>
868 | }
869 | >
870 | openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
871 | }
872 | }
873 |
874 | const cases: TestCase[] = [
875 | {
876 | name: 'Cyclic References with Full Descriptions',
877 | input: {
878 | openapi: '3.0.0',
879 | info: {
880 | title: 'Cyclic Test API',
881 | version: '1.0.0',
882 | },
883 | paths: {
884 | '/ab': {
885 | get: {
886 | operationId: 'getAB',
887 | summary: 'Get an A-B object',
888 | responses: {
889 | '200': {
890 | description: 'Returns an A object',
891 | content: {
892 | 'application/json': {
893 | schema: { $ref: '#/components/schemas/A' },
894 | },
895 | },
896 | },
897 | },
898 | },
899 | post: {
900 | operationId: 'createAB',
901 | summary: 'Create an A-B object',
902 | requestBody: {
903 | required: true,
904 | content: {
905 | 'application/json': {
906 | schema: {
907 | $ref: '#/components/schemas/A',
908 | description: 'A schema description',
909 | },
910 | },
911 | },
912 | },
913 | responses: {
914 | '201': {
915 | description: 'Created A object',
916 | content: {
917 | 'application/json': {
918 | schema: { $ref: '#/components/schemas/A' },
919 | },
920 | },
921 | },
922 | },
923 | },
924 | },
925 | },
926 | components: {
927 | schemas: {
928 | A: {
929 | type: 'object',
930 | description: 'A schema description',
931 | required: ['name', 'b'],
932 | properties: {
933 | name: {
934 | type: 'string',
935 | description: 'Name of A',
936 | },
937 | b: {
938 | $ref: '#/components/schemas/B',
939 | description: 'B property in A',
940 | },
941 | },
942 | },
943 | B: {
944 | type: 'object',
945 | description: 'B schema description',
946 | required: ['title', 'a'],
947 | properties: {
948 | title: {
949 | type: 'string',
950 | description: 'Title of B',
951 | },
952 | a: {
953 | $ref: '#/components/schemas/A',
954 | description: 'A property in B',
955 | },
956 | },
957 | },
958 | },
959 | },
960 | } as OpenAPIV3.Document,
961 | expected: {
962 | tools: {
963 | API: {
964 | methods: [
965 | {
966 | name: 'getAB',
967 | description: 'Get an A-B object',
968 | // Error responses might not be listed here since none are defined.
969 | // Just end the description with no Error Responses section.
970 | inputSchema: {
971 | type: 'object',
972 | properties: {},
973 | required: [],
974 | $defs: {
975 | A: {
976 | type: 'object',
977 | description: 'A schema description',
978 | additionalProperties: true,
979 | properties: {
980 | name: {
981 | type: 'string',
982 | description: 'Name of A',
983 | },
984 | b: {
985 | description: 'B property in A',
986 | $ref: '#/$defs/B',
987 | },
988 | },
989 | required: ['name', 'b'],
990 | },
991 | B: {
992 | type: 'object',
993 | description: 'B schema description',
994 | additionalProperties: true,
995 | properties: {
996 | title: {
997 | type: 'string',
998 | description: 'Title of B',
999 | },
1000 | a: {
1001 | description: 'A property in B',
1002 | $ref: '#/$defs/A',
1003 | },
1004 | },
1005 | required: ['title', 'a'],
1006 | },
1007 | },
1008 | },
1009 | returnSchema: {
1010 | $ref: '#/$defs/A',
1011 | description: 'Returns an A object',
1012 | $defs: {
1013 | A: {
1014 | type: 'object',
1015 | description: 'A schema description',
1016 | additionalProperties: true,
1017 | properties: {
1018 | name: {
1019 | type: 'string',
1020 | description: 'Name of A',
1021 | },
1022 | b: {
1023 | description: 'B property in A',
1024 | $ref: '#/$defs/B',
1025 | },
1026 | },
1027 | required: ['name', 'b'],
1028 | },
1029 | B: {
1030 | type: 'object',
1031 | description: 'B schema description',
1032 | additionalProperties: true,
1033 | properties: {
1034 | title: {
1035 | type: 'string',
1036 | description: 'Title of B',
1037 | },
1038 | a: {
1039 | description: 'A property in B',
1040 | $ref: '#/$defs/A',
1041 | },
1042 | },
1043 | required: ['title', 'a'],
1044 | },
1045 | },
1046 | },
1047 | },
1048 | {
1049 | name: 'createAB',
1050 | description: 'Create an A-B object',
1051 | inputSchema: {
1052 | type: 'object',
1053 | properties: {
1054 | // The requestBody references A. We keep it as a single body field with a $ref.
1055 | body: {
1056 | $ref: '#/$defs/A',
1057 | description: 'A schema description',
1058 | },
1059 | },
1060 | required: ['body'],
1061 |
1062 | $defs: {
1063 | A: {
1064 | type: 'object',
1065 | description: 'A schema description',
1066 | additionalProperties: true,
1067 | properties: {
1068 | name: {
1069 | type: 'string',
1070 | description: 'Name of A',
1071 | },
1072 | b: {
1073 | description: 'B property in A',
1074 | $ref: '#/$defs/B',
1075 | },
1076 | },
1077 | required: ['name', 'b'],
1078 | },
1079 | B: {
1080 | type: 'object',
1081 | description: 'B schema description',
1082 | additionalProperties: true,
1083 | properties: {
1084 | title: {
1085 | type: 'string',
1086 | description: 'Title of B',
1087 | },
1088 | a: {
1089 | description: 'A property in B',
1090 | $ref: '#/$defs/A',
1091 | },
1092 | },
1093 | required: ['title', 'a'],
1094 | },
1095 | },
1096 | },
1097 | returnSchema: {
1098 | $ref: '#/$defs/A',
1099 | description: 'Created A object',
1100 |
1101 | $defs: {
1102 | A: {
1103 | type: 'object',
1104 | description: 'A schema description',
1105 | additionalProperties: true,
1106 | properties: {
1107 | name: {
1108 | type: 'string',
1109 | description: 'Name of A',
1110 | },
1111 | b: {
1112 | description: 'B property in A',
1113 | $ref: '#/$defs/B',
1114 | },
1115 | },
1116 | required: ['name', 'b'],
1117 | },
1118 | B: {
1119 | type: 'object',
1120 | description: 'B schema description',
1121 | additionalProperties: true,
1122 | properties: {
1123 | title: {
1124 | type: 'string',
1125 | description: 'Title of B',
1126 | },
1127 | a: {
1128 | description: 'A property in B',
1129 | $ref: '#/$defs/A',
1130 | },
1131 | },
1132 | required: ['title', 'a'],
1133 | },
1134 | },
1135 | },
1136 | },
1137 | ],
1138 | },
1139 | },
1140 | openApiLookup: {
1141 | 'API-getAB': {
1142 | operationId: 'getAB',
1143 | summary: 'Get an A-B object',
1144 | responses: {
1145 | '200': {
1146 | description: 'Returns an A object',
1147 | content: {
1148 | 'application/json': {
1149 | schema: { $ref: '#/components/schemas/A' },
1150 | },
1151 | },
1152 | },
1153 | },
1154 | method: 'get',
1155 | path: '/ab',
1156 | },
1157 | 'API-createAB': {
1158 | operationId: 'createAB',
1159 | summary: 'Create an A-B object',
1160 | requestBody: {
1161 | required: true,
1162 | content: {
1163 | 'application/json': {
1164 | schema: {
1165 | $ref: '#/components/schemas/A',
1166 | description: 'A schema description',
1167 | },
1168 | },
1169 | },
1170 | },
1171 | responses: {
1172 | '201': {
1173 | description: 'Created A object',
1174 | content: {
1175 | 'application/json': {
1176 | schema: { $ref: '#/components/schemas/A' },
1177 | },
1178 | },
1179 | },
1180 | },
1181 | method: 'post',
1182 | path: '/ab',
1183 | },
1184 | },
1185 | },
1186 | },
1187 | {
1188 | name: 'allOf/oneOf References with Full Descriptions',
1189 | input: {
1190 | openapi: '3.0.0',
1191 | info: { title: 'Composed Schema API', version: '1.0.0' },
1192 | paths: {
1193 | '/composed': {
1194 | get: {
1195 | operationId: 'getComposed',
1196 | summary: 'Get a composed resource',
1197 | responses: {
1198 | '200': {
1199 | description: 'A composed object',
1200 | content: {
1201 | 'application/json': {
1202 | schema: { $ref: '#/components/schemas/C' },
1203 | },
1204 | },
1205 | },
1206 | },
1207 | },
1208 | },
1209 | },
1210 | components: {
1211 | schemas: {
1212 | Base: {
1213 | type: 'object',
1214 | description: 'Base schema description',
1215 | properties: {
1216 | baseName: {
1217 | type: 'string',
1218 | description: 'Name in the base schema',
1219 | },
1220 | },
1221 | },
1222 | D: {
1223 | type: 'object',
1224 | description: 'D schema description',
1225 | properties: {
1226 | dProp: {
1227 | type: 'integer',
1228 | description: 'D property integer',
1229 | },
1230 | },
1231 | },
1232 | E: {
1233 | type: 'object',
1234 | description: 'E schema description',
1235 | properties: {
1236 | choice: {
1237 | description: 'One of these choices',
1238 | oneOf: [
1239 | {
1240 | $ref: '#/components/schemas/F',
1241 | },
1242 | {
1243 | $ref: '#/components/schemas/G',
1244 | },
1245 | ],
1246 | },
1247 | },
1248 | },
1249 | F: {
1250 | type: 'object',
1251 | description: 'F schema description',
1252 | properties: {
1253 | fVal: {
1254 | type: 'boolean',
1255 | description: 'Boolean in F',
1256 | },
1257 | },
1258 | },
1259 | G: {
1260 | type: 'object',
1261 | description: 'G schema description',
1262 | properties: {
1263 | gVal: {
1264 | type: 'string',
1265 | description: 'String in G',
1266 | },
1267 | },
1268 | },
1269 | C: {
1270 | description: 'C schema description',
1271 | allOf: [{ $ref: '#/components/schemas/Base' }, { $ref: '#/components/schemas/D' }, { $ref: '#/components/schemas/E' }],
1272 | },
1273 | },
1274 | },
1275 | } as OpenAPIV3.Document,
1276 | expected: {
1277 | tools: {
1278 | API: {
1279 | methods: [
1280 | {
1281 | name: 'getComposed',
1282 | description: 'Get a composed resource',
1283 | inputSchema: {
1284 | type: 'object',
1285 | properties: {},
1286 | required: [],
1287 | $defs: {
1288 | Base: {
1289 | type: 'object',
1290 | description: 'Base schema description',
1291 | additionalProperties: true,
1292 | properties: {
1293 | baseName: {
1294 | type: 'string',
1295 | description: 'Name in the base schema',
1296 | },
1297 | },
1298 | },
1299 | C: {
1300 | description: 'C schema description',
1301 | allOf: [{ $ref: '#/$defs/Base' }, { $ref: '#/$defs/D' }, { $ref: '#/$defs/E' }],
1302 | },
1303 | D: {
1304 | type: 'object',
1305 | additionalProperties: true,
1306 | description: 'D schema description',
1307 | properties: {
1308 | dProp: {
1309 | type: 'integer',
1310 | description: 'D property integer',
1311 | },
1312 | },
1313 | },
1314 | E: {
1315 | type: 'object',
1316 | additionalProperties: true,
1317 | description: 'E schema description',
1318 | properties: {
1319 | choice: {
1320 | description: 'One of these choices',
1321 | oneOf: [{ $ref: '#/$defs/F' }, { $ref: '#/$defs/G' }],
1322 | },
1323 | },
1324 | },
1325 | F: {
1326 | type: 'object',
1327 | additionalProperties: true,
1328 | description: 'F schema description',
1329 | properties: {
1330 | fVal: {
1331 | type: 'boolean',
1332 | description: 'Boolean in F',
1333 | },
1334 | },
1335 | },
1336 | G: {
1337 | type: 'object',
1338 | additionalProperties: true,
1339 | description: 'G schema description',
1340 | properties: {
1341 | gVal: {
1342 | type: 'string',
1343 | description: 'String in G',
1344 | },
1345 | },
1346 | },
1347 | },
1348 | },
1349 | returnSchema: {
1350 | $ref: '#/$defs/C',
1351 | description: 'A composed object',
1352 | $defs: {
1353 | Base: {
1354 | type: 'object',
1355 | description: 'Base schema description',
1356 | additionalProperties: true,
1357 | properties: {
1358 | baseName: {
1359 | type: 'string',
1360 | description: 'Name in the base schema',
1361 | },
1362 | },
1363 | },
1364 | C: {
1365 | description: 'C schema description',
1366 | allOf: [{ $ref: '#/$defs/Base' }, { $ref: '#/$defs/D' }, { $ref: '#/$defs/E' }],
1367 | },
1368 | D: {
1369 | type: 'object',
1370 | additionalProperties: true,
1371 | description: 'D schema description',
1372 | properties: {
1373 | dProp: {
1374 | type: 'integer',
1375 | description: 'D property integer',
1376 | },
1377 | },
1378 | },
1379 | E: {
1380 | type: 'object',
1381 | additionalProperties: true,
1382 | description: 'E schema description',
1383 | properties: {
1384 | choice: {
1385 | description: 'One of these choices',
1386 | oneOf: [{ $ref: '#/$defs/F' }, { $ref: '#/$defs/G' }],
1387 | },
1388 | },
1389 | },
1390 | F: {
1391 | type: 'object',
1392 | additionalProperties: true,
1393 | description: 'F schema description',
1394 | properties: {
1395 | fVal: {
1396 | type: 'boolean',
1397 | description: 'Boolean in F',
1398 | },
1399 | },
1400 | },
1401 | G: {
1402 | type: 'object',
1403 | additionalProperties: true,
1404 | description: 'G schema description',
1405 | properties: {
1406 | gVal: {
1407 | type: 'string',
1408 | description: 'String in G',
1409 | },
1410 | },
1411 | },
1412 | },
1413 | },
1414 | },
1415 | ],
1416 | },
1417 | },
1418 | openApiLookup: {
1419 | 'API-getComposed': {
1420 | operationId: 'getComposed',
1421 | summary: 'Get a composed resource',
1422 | responses: {
1423 | '200': {
1424 | description: 'A composed object',
1425 | content: {
1426 | 'application/json': {
1427 | schema: { $ref: '#/components/schemas/C' },
1428 | },
1429 | },
1430 | },
1431 | },
1432 | method: 'get',
1433 | path: '/composed',
1434 | },
1435 | },
1436 | },
1437 | },
1438 | ]
1439 |
1440 | it.each(cases)('$name', ({ input, expected }) => {
1441 | const converter = new OpenAPIToMCPConverter(input)
1442 | const { tools, openApiLookup } = converter.convertToMCPTools()
1443 |
1444 | // Use the custom verification instead of direct equality
1445 | verifyTools(tools, expected.tools)
1446 | expect(openApiLookup).toEqual(expected.openApiLookup)
1447 | })
1448 | })
1449 |
```