@mieubrisse/notion-mcp-server 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.devcontainer/devcontainer.json +4 -0
  2. package/.dockerignore +3 -0
  3. package/.github/pull_request_template.md +8 -0
  4. package/.github/workflows/ci.yml +42 -0
  5. package/Dockerfile +36 -0
  6. package/LICENSE +7 -0
  7. package/README.md +412 -0
  8. package/docker-compose.yml +6 -0
  9. package/docs/images/connections.png +0 -0
  10. package/docs/images/integration-access.png +0 -0
  11. package/docs/images/integrations-capabilities.png +0 -0
  12. package/docs/images/integrations-creation.png +0 -0
  13. package/docs/images/page-access-edit.png +0 -0
  14. package/package.json +63 -0
  15. package/scripts/build-cli.js +30 -0
  16. package/scripts/notion-openapi.json +2238 -0
  17. package/scripts/start-server.ts +243 -0
  18. package/src/init-server.ts +50 -0
  19. package/src/openapi-mcp-server/README.md +3 -0
  20. package/src/openapi-mcp-server/auth/index.ts +2 -0
  21. package/src/openapi-mcp-server/auth/template.ts +24 -0
  22. package/src/openapi-mcp-server/auth/types.ts +26 -0
  23. package/src/openapi-mcp-server/client/__tests__/http-client-upload.test.ts +205 -0
  24. package/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts +282 -0
  25. package/src/openapi-mcp-server/client/__tests__/http-client.test.ts +537 -0
  26. package/src/openapi-mcp-server/client/http-client.ts +198 -0
  27. package/src/openapi-mcp-server/client/polyfill-headers.ts +42 -0
  28. package/src/openapi-mcp-server/index.ts +3 -0
  29. package/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts +479 -0
  30. package/src/openapi-mcp-server/mcp/proxy.ts +250 -0
  31. package/src/openapi-mcp-server/openapi/__tests__/file-upload.test.ts +100 -0
  32. package/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts +602 -0
  33. package/src/openapi-mcp-server/openapi/__tests__/parser.test.ts +1448 -0
  34. package/src/openapi-mcp-server/openapi/file-upload.ts +40 -0
  35. package/src/openapi-mcp-server/openapi/parser.ts +529 -0
  36. package/tsconfig.json +26 -0
@@ -0,0 +1,1448 @@
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
+ })