@opencontextprotocol/agent 0.1.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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/dist/src/agent.d.ts +112 -0
  4. package/dist/src/agent.d.ts.map +1 -0
  5. package/dist/src/agent.js +358 -0
  6. package/dist/src/agent.js.map +1 -0
  7. package/dist/src/context.d.ts +108 -0
  8. package/dist/src/context.d.ts.map +1 -0
  9. package/dist/src/context.js +196 -0
  10. package/dist/src/context.js.map +1 -0
  11. package/dist/src/errors.d.ts +40 -0
  12. package/dist/src/errors.d.ts.map +1 -0
  13. package/dist/src/errors.js +63 -0
  14. package/dist/src/errors.js.map +1 -0
  15. package/dist/src/headers.d.ts +63 -0
  16. package/dist/src/headers.d.ts.map +1 -0
  17. package/dist/src/headers.js +238 -0
  18. package/dist/src/headers.js.map +1 -0
  19. package/dist/src/http_client.d.ts +82 -0
  20. package/dist/src/http_client.d.ts.map +1 -0
  21. package/dist/src/http_client.js +181 -0
  22. package/dist/src/http_client.js.map +1 -0
  23. package/dist/src/index.d.ts +25 -0
  24. package/dist/src/index.d.ts.map +1 -0
  25. package/dist/src/index.js +35 -0
  26. package/dist/src/index.js.map +1 -0
  27. package/dist/src/registry.d.ts +52 -0
  28. package/dist/src/registry.d.ts.map +1 -0
  29. package/dist/src/registry.js +164 -0
  30. package/dist/src/registry.js.map +1 -0
  31. package/dist/src/schema_discovery.d.ts +149 -0
  32. package/dist/src/schema_discovery.d.ts.map +1 -0
  33. package/dist/src/schema_discovery.js +707 -0
  34. package/dist/src/schema_discovery.js.map +1 -0
  35. package/dist/src/schemas/ocp-context.json +138 -0
  36. package/dist/src/storage.d.ts +110 -0
  37. package/dist/src/storage.d.ts.map +1 -0
  38. package/dist/src/storage.js +399 -0
  39. package/dist/src/storage.js.map +1 -0
  40. package/dist/src/validation.d.ts +169 -0
  41. package/dist/src/validation.d.ts.map +1 -0
  42. package/dist/src/validation.js +92 -0
  43. package/dist/src/validation.js.map +1 -0
  44. package/dist/tests/agent.test.d.ts +5 -0
  45. package/dist/tests/agent.test.d.ts.map +1 -0
  46. package/dist/tests/agent.test.js +536 -0
  47. package/dist/tests/agent.test.js.map +1 -0
  48. package/dist/tests/context.test.d.ts +5 -0
  49. package/dist/tests/context.test.d.ts.map +1 -0
  50. package/dist/tests/context.test.js +285 -0
  51. package/dist/tests/context.test.js.map +1 -0
  52. package/dist/tests/headers.test.d.ts +5 -0
  53. package/dist/tests/headers.test.d.ts.map +1 -0
  54. package/dist/tests/headers.test.js +356 -0
  55. package/dist/tests/headers.test.js.map +1 -0
  56. package/dist/tests/http_client.test.d.ts +5 -0
  57. package/dist/tests/http_client.test.d.ts.map +1 -0
  58. package/dist/tests/http_client.test.js +373 -0
  59. package/dist/tests/http_client.test.js.map +1 -0
  60. package/dist/tests/registry.test.d.ts +5 -0
  61. package/dist/tests/registry.test.d.ts.map +1 -0
  62. package/dist/tests/registry.test.js +232 -0
  63. package/dist/tests/registry.test.js.map +1 -0
  64. package/dist/tests/schema_discovery.test.d.ts +5 -0
  65. package/dist/tests/schema_discovery.test.d.ts.map +1 -0
  66. package/dist/tests/schema_discovery.test.js +1074 -0
  67. package/dist/tests/schema_discovery.test.js.map +1 -0
  68. package/dist/tests/storage.test.d.ts +5 -0
  69. package/dist/tests/storage.test.d.ts.map +1 -0
  70. package/dist/tests/storage.test.js +414 -0
  71. package/dist/tests/storage.test.js.map +1 -0
  72. package/dist/tests/validation.test.d.ts +5 -0
  73. package/dist/tests/validation.test.d.ts.map +1 -0
  74. package/dist/tests/validation.test.js +254 -0
  75. package/dist/tests/validation.test.js.map +1 -0
  76. package/package.json +51 -0
@@ -0,0 +1,1074 @@
1
+ /**
2
+ * Tests for OCP schema discovery functionality.
3
+ */
4
+ import { describe, test, expect, beforeEach, jest } from '@jest/globals';
5
+ import { OCPSchemaDiscovery } from '../src/schema_discovery.js';
6
+ import { dirname } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ // Mock fetch globally
11
+ global.fetch = jest.fn();
12
+ describe('OCP Schema Discovery', () => {
13
+ let discovery;
14
+ const sampleOpenApiSpec = {
15
+ openapi: '3.0.0',
16
+ info: {
17
+ title: 'Test API',
18
+ version: '1.0.0',
19
+ },
20
+ servers: [{ url: 'https://api.example.com' }],
21
+ paths: {
22
+ '/users': {
23
+ get: {
24
+ summary: 'List users',
25
+ description: 'Get a list of all users',
26
+ parameters: [
27
+ {
28
+ name: 'limit',
29
+ in: 'query',
30
+ schema: { type: 'integer' },
31
+ required: false,
32
+ },
33
+ ],
34
+ responses: {
35
+ '200': {
36
+ description: 'List of users',
37
+ content: {
38
+ 'application/json': {
39
+ schema: {
40
+ type: 'array',
41
+ items: {
42
+ type: 'object',
43
+ properties: {
44
+ id: { type: 'integer' },
45
+ name: { type: 'string' },
46
+ email: { type: 'string' },
47
+ },
48
+ },
49
+ },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ post: {
56
+ summary: 'Create user',
57
+ description: 'Create a new user',
58
+ requestBody: {
59
+ content: {
60
+ 'application/json': {
61
+ schema: {
62
+ type: 'object',
63
+ properties: {
64
+ name: { type: 'string' },
65
+ email: { type: 'string' },
66
+ },
67
+ required: ['name', 'email'],
68
+ },
69
+ },
70
+ },
71
+ },
72
+ responses: {
73
+ '201': {
74
+ description: 'User created',
75
+ content: {
76
+ 'application/json': {
77
+ schema: {
78
+ type: 'object',
79
+ properties: {
80
+ id: { type: 'integer' },
81
+ name: { type: 'string' },
82
+ email: { type: 'string' },
83
+ },
84
+ },
85
+ },
86
+ },
87
+ },
88
+ },
89
+ },
90
+ },
91
+ '/users/{id}': {
92
+ get: {
93
+ summary: 'Get user',
94
+ description: 'Get a specific user by ID',
95
+ parameters: [
96
+ {
97
+ name: 'id',
98
+ in: 'path',
99
+ schema: { type: 'string' },
100
+ required: true,
101
+ },
102
+ ],
103
+ responses: {
104
+ '200': {
105
+ description: 'User details',
106
+ content: {
107
+ 'application/json': {
108
+ schema: {
109
+ type: 'object',
110
+ properties: {
111
+ id: { type: 'integer' },
112
+ name: { type: 'string' },
113
+ email: { type: 'string' },
114
+ },
115
+ },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ },
123
+ };
124
+ beforeEach(() => {
125
+ jest.clearAllMocks();
126
+ discovery = new OCPSchemaDiscovery();
127
+ });
128
+ describe('Parse OpenAPI Spec', () => {
129
+ test('parse openapi spec', () => {
130
+ const apiSpec = discovery._parseOpenApiSpec(sampleOpenApiSpec, 'https://api.example.com');
131
+ expect(apiSpec.title).toBe('Test API');
132
+ expect(apiSpec.version).toBe('1.0.0');
133
+ expect(apiSpec.base_url).toBe('https://api.example.com');
134
+ expect(apiSpec.tools.length).toBe(3); // GET /users, POST /users, GET /users/{id}
135
+ });
136
+ test('generate tools from spec', () => {
137
+ const apiSpec = discovery._parseOpenApiSpec(sampleOpenApiSpec, 'https://api.example.com');
138
+ const tools = apiSpec.tools;
139
+ expect(tools.length).toBe(3); // GET /users, POST /users, GET /users/{id}
140
+ // Check that we have the expected tools with deterministic names
141
+ const toolNames = tools.map((t) => t.name);
142
+ const expectedNames = ['getUsers', 'postUsers', 'getUsersId'];
143
+ for (const expectedName of expectedNames) {
144
+ expect(toolNames).toContain(expectedName);
145
+ }
146
+ // Check GET /users tool
147
+ const getUsers = tools.find((t) => t.name === 'getUsers');
148
+ expect(getUsers).toBeDefined();
149
+ expect(getUsers.method).toBe('GET');
150
+ expect(getUsers.path).toBe('/users');
151
+ expect(getUsers.description).toBe('List users');
152
+ expect(getUsers.parameters['limit']).toBeDefined();
153
+ expect(getUsers.parameters['limit'].type).toBe('integer');
154
+ expect(getUsers.parameters['limit'].location).toBe('query');
155
+ expect(getUsers.parameters['limit'].required).toBe(false);
156
+ expect(getUsers.response_schema).toBeDefined();
157
+ expect(getUsers.response_schema.type).toBe('array');
158
+ // Check POST /users tool
159
+ const postUsers = tools.find((t) => t.name === 'postUsers');
160
+ expect(postUsers).toBeDefined();
161
+ expect(postUsers.method).toBe('POST');
162
+ expect(postUsers.path).toBe('/users');
163
+ expect(postUsers.parameters['name']).toBeDefined();
164
+ expect(postUsers.parameters['email']).toBeDefined();
165
+ expect(postUsers.parameters['name'].required).toBe(true);
166
+ expect(postUsers.parameters['email'].required).toBe(true);
167
+ expect(postUsers.response_schema).toBeDefined();
168
+ expect(postUsers.response_schema.type).toBe('object');
169
+ // Check GET /users/{id} tool
170
+ const getUsersId = tools.find((t) => t.name === 'getUsersId');
171
+ expect(getUsersId).toBeDefined();
172
+ expect(getUsersId.method).toBe('GET');
173
+ expect(getUsersId.path).toBe('/users/{id}');
174
+ expect(getUsersId.parameters['id']).toBeDefined();
175
+ expect(getUsersId.parameters['id'].location).toBe('path');
176
+ expect(getUsersId.parameters['id'].required).toBe(true);
177
+ expect(getUsersId.response_schema).toBeDefined();
178
+ expect(getUsersId.response_schema.type).toBe('object');
179
+ });
180
+ });
181
+ describe('Discover API', () => {
182
+ test('discover api success', async () => {
183
+ // Mock fetch response
184
+ global.fetch.mockResolvedValue({
185
+ ok: true,
186
+ json: async () => sampleOpenApiSpec,
187
+ });
188
+ const apiSpec = await discovery.discoverApi('https://api.example.com/openapi.json');
189
+ expect(apiSpec.title).toBe('Test API');
190
+ expect(apiSpec.tools.length).toBe(3);
191
+ expect(global.fetch).toHaveBeenCalledTimes(1);
192
+ });
193
+ test('discover api with base url override', async () => {
194
+ global.fetch.mockResolvedValue({
195
+ ok: true,
196
+ json: async () => sampleOpenApiSpec,
197
+ });
198
+ const apiSpec = await discovery.discoverApi('https://api.example.com/openapi.json', 'https://custom.example.com');
199
+ expect(apiSpec.base_url).toBe('https://custom.example.com');
200
+ });
201
+ test('discover api failure', async () => {
202
+ global.fetch.mockRejectedValue(new Error('Network error'));
203
+ await expect(discovery.discoverApi('https://api.example.com/openapi.json')).rejects.toThrow('Network error');
204
+ });
205
+ test('discover api with refs', async () => {
206
+ const openApiSpecWithRefs = {
207
+ openapi: '3.0.0',
208
+ info: { title: 'Test API', version: '1.0.0' },
209
+ servers: [{ url: 'https://api.example.com' }],
210
+ paths: {
211
+ '/queue': {
212
+ post: {
213
+ operationId: 'updateQueue',
214
+ summary: 'Update queue',
215
+ responses: {
216
+ '200': {
217
+ description: 'Queue updated',
218
+ content: {
219
+ 'application/json': {
220
+ schema: {
221
+ $ref: '#/components/schemas/Queue',
222
+ },
223
+ },
224
+ },
225
+ },
226
+ },
227
+ },
228
+ },
229
+ },
230
+ components: {
231
+ schemas: {
232
+ Queue: {
233
+ type: 'object',
234
+ properties: {
235
+ sid: { type: 'string' },
236
+ friendly_name: { type: 'string' },
237
+ current_size: { type: 'integer' },
238
+ },
239
+ },
240
+ },
241
+ },
242
+ };
243
+ global.fetch.mockResolvedValueOnce({
244
+ ok: true,
245
+ json: async () => openApiSpecWithRefs,
246
+ });
247
+ const apiSpec = await discovery.discoverApi('https://api.example.com/openapi.json');
248
+ // Should have one tool
249
+ expect(apiSpec.tools.length).toBe(1);
250
+ const tool = apiSpec.tools[0];
251
+ // Verify the $ref was resolved
252
+ expect(tool.name).toBe('updateQueue');
253
+ expect(tool.response_schema).toBeDefined();
254
+ expect(tool.response_schema?.type).toBe('object');
255
+ expect(tool.response_schema?.properties).toBeDefined();
256
+ expect(tool.response_schema?.properties?.sid).toBeDefined();
257
+ expect(tool.response_schema?.properties?.friendly_name).toBeDefined();
258
+ expect(tool.response_schema?.properties?.current_size).toBeDefined();
259
+ // Should NOT contain $ref anymore
260
+ expect(JSON.stringify(tool.response_schema)).not.toContain('$ref');
261
+ });
262
+ test('discover api with circular refs', async () => {
263
+ const openApiSpecWithCircularRefs = {
264
+ openapi: '3.0.0',
265
+ info: { title: 'Test API', version: '1.0.0' },
266
+ servers: [{ url: 'https://api.example.com' }],
267
+ paths: {
268
+ '/node': {
269
+ get: {
270
+ operationId: 'getNode',
271
+ summary: 'Get node',
272
+ responses: {
273
+ '200': {
274
+ description: 'Node retrieved',
275
+ content: {
276
+ 'application/json': {
277
+ schema: {
278
+ $ref: '#/components/schemas/Node',
279
+ },
280
+ },
281
+ },
282
+ },
283
+ },
284
+ },
285
+ },
286
+ },
287
+ components: {
288
+ schemas: {
289
+ Node: {
290
+ type: 'object',
291
+ properties: {
292
+ id: { type: 'string' },
293
+ children: {
294
+ type: 'array',
295
+ items: {
296
+ $ref: '#/components/schemas/Node',
297
+ },
298
+ },
299
+ },
300
+ },
301
+ },
302
+ },
303
+ };
304
+ global.fetch.mockResolvedValueOnce({
305
+ ok: true,
306
+ json: async () => openApiSpecWithCircularRefs,
307
+ });
308
+ // Should not raise an error
309
+ const apiSpec = await discovery.discoverApi('https://api.example.com/openapi.json');
310
+ // Should have one tool
311
+ expect(apiSpec.tools.length).toBe(1);
312
+ const tool = apiSpec.tools[0];
313
+ // Verify the response schema exists and has the expected structure
314
+ expect(tool.response_schema).toBeDefined();
315
+ expect(tool.response_schema?.type).toBe('object');
316
+ expect(tool.response_schema?.properties).toBeDefined();
317
+ expect(tool.response_schema?.properties?.id).toBeDefined();
318
+ expect(tool.response_schema?.properties?.children).toBeDefined();
319
+ // The circular ref in children.items should be replaced with a placeholder
320
+ const childrenSchema = tool.response_schema?.properties?.children;
321
+ expect(childrenSchema?.type).toBe('array');
322
+ expect(childrenSchema?.items).toBeDefined();
323
+ // The circular ref should be broken with a placeholder
324
+ expect(childrenSchema?.items?.description).toBe('Circular reference');
325
+ });
326
+ test('discover api with polymorphic keywords', async () => {
327
+ const openApiSpecWithPolymorphic = {
328
+ openapi: '3.0.0',
329
+ info: { title: 'Test API', version: '1.0.0' },
330
+ servers: [{ url: 'https://api.example.com' }],
331
+ paths: {
332
+ '/payment': {
333
+ get: {
334
+ operationId: 'getPayment',
335
+ summary: 'Get payment',
336
+ responses: {
337
+ '200': {
338
+ description: 'Payment retrieved',
339
+ content: {
340
+ 'application/json': {
341
+ schema: {
342
+ $ref: '#/components/schemas/Payment',
343
+ },
344
+ },
345
+ },
346
+ },
347
+ },
348
+ },
349
+ },
350
+ },
351
+ components: {
352
+ schemas: {
353
+ Payment: {
354
+ type: 'object',
355
+ properties: {
356
+ id: { type: 'string' },
357
+ amount: { type: 'integer' },
358
+ status: {
359
+ anyOf: [
360
+ { type: 'string' },
361
+ { type: 'number' },
362
+ ],
363
+ },
364
+ source: {
365
+ anyOf: [
366
+ { $ref: '#/components/schemas/Card' },
367
+ { $ref: '#/components/schemas/BankAccount' },
368
+ { $ref: '#/components/schemas/Wallet' },
369
+ ],
370
+ },
371
+ },
372
+ },
373
+ Card: {
374
+ type: 'object',
375
+ properties: {
376
+ brand: { type: 'string' },
377
+ last4: { type: 'string' },
378
+ },
379
+ },
380
+ BankAccount: {
381
+ type: 'object',
382
+ properties: {
383
+ routing_number: { type: 'string' },
384
+ account_number: { type: 'string' },
385
+ },
386
+ },
387
+ Wallet: {
388
+ type: 'object',
389
+ properties: {
390
+ provider: { type: 'string' },
391
+ wallet_id: { type: 'string' },
392
+ },
393
+ },
394
+ },
395
+ },
396
+ };
397
+ global.fetch.mockResolvedValueOnce({
398
+ ok: true,
399
+ json: async () => openApiSpecWithPolymorphic,
400
+ });
401
+ // Discover API
402
+ const apiSpec = await discovery.discoverApi('https://api.example.com/openapi.json');
403
+ // Should have one tool
404
+ expect(apiSpec.tools.length).toBe(1);
405
+ const tool = apiSpec.tools[0];
406
+ // Verify the response schema exists
407
+ expect(tool.response_schema).toBeDefined();
408
+ expect(tool.response_schema?.type).toBe('object');
409
+ expect(tool.response_schema?.properties).toBeDefined();
410
+ // Status field with anyOf should have string and number types
411
+ const statusSchema = tool?.response_schema?.properties?.status;
412
+ expect(statusSchema?.anyOf).toBeDefined();
413
+ expect(statusSchema?.anyOf[0]).toEqual({ type: 'string' });
414
+ expect(statusSchema?.anyOf[1]).toEqual({ type: 'number' });
415
+ // Should not contain any $refs
416
+ expect(JSON.stringify(statusSchema)).not.toContain('$ref');
417
+ // Source field with object $refs in anyOf should keep the refs unresolved
418
+ const sourceSchema = tool.response_schema?.properties?.source;
419
+ expect(sourceSchema?.anyOf).toBeDefined();
420
+ // The $refs to object schemas should be preserved
421
+ expect(sourceSchema?.anyOf[0]).toEqual({ $ref: '#/components/schemas/Card' });
422
+ expect(sourceSchema?.anyOf[1]).toEqual({ $ref: '#/components/schemas/BankAccount' });
423
+ expect(sourceSchema?.anyOf[2]).toEqual({ $ref: '#/components/schemas/Wallet' });
424
+ });
425
+ });
426
+ describe('Search Tools', () => {
427
+ test('search tools', () => {
428
+ // Create some sample tools
429
+ const tools = [
430
+ {
431
+ name: 'list_users',
432
+ description: 'Get all users from the system',
433
+ method: 'GET',
434
+ path: '/users',
435
+ parameters: {},
436
+ response_schema: undefined,
437
+ operation_id: undefined,
438
+ tags: [],
439
+ },
440
+ {
441
+ name: 'create_user',
442
+ description: 'Create a new user account',
443
+ method: 'POST',
444
+ path: '/users',
445
+ parameters: {},
446
+ response_schema: undefined,
447
+ operation_id: undefined,
448
+ tags: [],
449
+ },
450
+ {
451
+ name: 'list_orders',
452
+ description: 'Get customer orders',
453
+ method: 'GET',
454
+ path: '/orders',
455
+ parameters: {},
456
+ response_schema: undefined,
457
+ operation_id: undefined,
458
+ tags: [],
459
+ },
460
+ ];
461
+ const apiSpec = {
462
+ title: 'Test API',
463
+ version: '1.0.0',
464
+ base_url: 'https://api.example.com',
465
+ description: 'A test API for testing purposes',
466
+ tools: tools,
467
+ raw_spec: {},
468
+ };
469
+ // Test search by name
470
+ const userTools = discovery.searchTools(apiSpec, 'user');
471
+ expect(userTools.length).toBe(2);
472
+ expect(userTools.every((tool) => tool.name.toLowerCase().includes('user') ||
473
+ tool.description.toLowerCase().includes('user'))).toBe(true);
474
+ // Test search by description
475
+ const createTools = discovery.searchTools(apiSpec, 'create');
476
+ expect(createTools.length).toBe(1);
477
+ expect(createTools[0].name).toBe('create_user');
478
+ // Test no matches
479
+ const noMatches = discovery.searchTools(apiSpec, 'nonexistent');
480
+ expect(noMatches.length).toBe(0);
481
+ });
482
+ });
483
+ describe('Generate Documentation', () => {
484
+ test('generate tool documentation', () => {
485
+ const tool = {
486
+ name: 'create_user',
487
+ description: 'Create a new user account',
488
+ method: 'POST',
489
+ path: '/users',
490
+ parameters: {
491
+ name: {
492
+ type: 'string',
493
+ description: "User's full name",
494
+ required: true,
495
+ location: 'body',
496
+ },
497
+ email: {
498
+ type: 'string',
499
+ description: "User's email address",
500
+ required: true,
501
+ location: 'body',
502
+ },
503
+ age: {
504
+ type: 'integer',
505
+ description: "User's age",
506
+ required: false,
507
+ location: 'body',
508
+ },
509
+ },
510
+ response_schema: undefined,
511
+ operation_id: undefined,
512
+ tags: [],
513
+ };
514
+ const doc = discovery.generateToolDocumentation(tool);
515
+ expect(doc).toContain('create_user');
516
+ expect(doc).toContain('Create a new user account');
517
+ expect(doc).toContain('POST');
518
+ expect(doc).toContain('/users');
519
+ expect(doc).toContain('name');
520
+ expect(doc).toContain('email');
521
+ expect(doc).toContain('age');
522
+ expect(doc.toLowerCase()).toContain('required');
523
+ });
524
+ });
525
+ describe('OCPTool', () => {
526
+ test('tool creation', () => {
527
+ const tool = {
528
+ name: 'test_tool',
529
+ description: 'A test tool',
530
+ method: 'GET',
531
+ path: '/test',
532
+ parameters: { param: { type: 'string' } },
533
+ response_schema: undefined,
534
+ operation_id: undefined,
535
+ tags: [],
536
+ };
537
+ expect(tool.name).toBe('test_tool');
538
+ expect(tool.description).toBe('A test tool');
539
+ expect(tool.method).toBe('GET');
540
+ expect(tool.path).toBe('/test');
541
+ expect(tool.parameters['param'].type).toBe('string');
542
+ });
543
+ });
544
+ describe('OCPAPISpec', () => {
545
+ test('api spec creation', () => {
546
+ const tools = [
547
+ {
548
+ name: 'tool1',
549
+ description: 'Description 1',
550
+ method: 'GET',
551
+ path: '/path1',
552
+ parameters: {},
553
+ response_schema: undefined,
554
+ operation_id: undefined,
555
+ tags: [],
556
+ },
557
+ {
558
+ name: 'tool2',
559
+ description: 'Description 2',
560
+ method: 'POST',
561
+ path: '/path2',
562
+ parameters: {},
563
+ response_schema: undefined,
564
+ operation_id: undefined,
565
+ tags: [],
566
+ },
567
+ ];
568
+ const apiSpec = {
569
+ title: 'Test API',
570
+ version: '1.0.0',
571
+ base_url: 'https://api.example.com',
572
+ description: 'A test API for testing purposes',
573
+ tools: tools,
574
+ raw_spec: {},
575
+ };
576
+ expect(apiSpec.title).toBe('Test API');
577
+ expect(apiSpec.version).toBe('1.0.0');
578
+ expect(apiSpec.base_url).toBe('https://api.example.com');
579
+ expect(apiSpec.description).toBe('A test API for testing purposes');
580
+ expect(apiSpec.tools.length).toBe(2);
581
+ expect(apiSpec.tools[0].name).toBe('tool1');
582
+ expect(apiSpec.tools[1].name).toBe('tool2');
583
+ });
584
+ });
585
+ describe('Swagger 2.0 Support', () => {
586
+ const swagger2Spec = {
587
+ swagger: '2.0',
588
+ info: {
589
+ title: 'Swagger 2.0 API',
590
+ version: '1.0.0',
591
+ description: 'A test API using Swagger 2.0'
592
+ },
593
+ host: 'api.example.com',
594
+ basePath: '/v1',
595
+ schemes: ['https'],
596
+ paths: {
597
+ '/users': {
598
+ get: {
599
+ operationId: 'getUsers',
600
+ summary: 'List users',
601
+ description: 'Get a list of all users',
602
+ parameters: [
603
+ {
604
+ name: 'limit',
605
+ in: 'query',
606
+ type: 'integer',
607
+ required: false
608
+ }
609
+ ],
610
+ responses: {
611
+ '200': {
612
+ description: 'List of users',
613
+ schema: {
614
+ type: 'array',
615
+ items: {
616
+ type: 'object',
617
+ properties: {
618
+ id: { type: 'integer' },
619
+ name: { type: 'string' },
620
+ email: { type: 'string' }
621
+ }
622
+ }
623
+ }
624
+ }
625
+ }
626
+ },
627
+ post: {
628
+ operationId: 'createUser',
629
+ summary: 'Create user',
630
+ description: 'Create a new user',
631
+ parameters: [
632
+ {
633
+ name: 'body',
634
+ in: 'body',
635
+ required: true,
636
+ schema: {
637
+ type: 'object',
638
+ properties: {
639
+ name: { type: 'string' },
640
+ email: { type: 'string' }
641
+ },
642
+ required: ['name', 'email']
643
+ }
644
+ }
645
+ ],
646
+ responses: {
647
+ '201': {
648
+ description: 'User created',
649
+ schema: {
650
+ type: 'object',
651
+ properties: {
652
+ id: { type: 'integer' },
653
+ name: { type: 'string' },
654
+ email: { type: 'string' }
655
+ }
656
+ }
657
+ }
658
+ }
659
+ }
660
+ },
661
+ '/users/{id}': {
662
+ get: {
663
+ operationId: 'getUserById',
664
+ summary: 'Get user',
665
+ description: 'Get a specific user by ID',
666
+ parameters: [
667
+ {
668
+ name: 'id',
669
+ in: 'path',
670
+ type: 'string',
671
+ required: true
672
+ }
673
+ ],
674
+ responses: {
675
+ '200': {
676
+ description: 'User details',
677
+ schema: {
678
+ type: 'object',
679
+ properties: {
680
+ id: { type: 'integer' },
681
+ name: { type: 'string' },
682
+ email: { type: 'string' }
683
+ }
684
+ }
685
+ }
686
+ }
687
+ }
688
+ }
689
+ }
690
+ };
691
+ it('should detect Swagger 2.0 version', () => {
692
+ const discovery = new OCPSchemaDiscovery();
693
+ const version = discovery._detectSpecVersion(swagger2Spec);
694
+ expect(version).toBe('swagger_2');
695
+ });
696
+ it('should extract base URL from Swagger 2.0 (host + basePath + schemes)', () => {
697
+ const discovery = new OCPSchemaDiscovery();
698
+ discovery._specVersion = 'swagger_2';
699
+ const baseUrl = discovery._extractBaseUrl(swagger2Spec);
700
+ expect(baseUrl).toBe('https://api.example.com/v1');
701
+ });
702
+ it('should extract base URL with multiple schemes (uses first one)', () => {
703
+ const discovery = new OCPSchemaDiscovery();
704
+ const spec = {
705
+ swagger: '2.0',
706
+ host: 'api.example.com',
707
+ basePath: '/api',
708
+ schemes: ['http', 'https']
709
+ };
710
+ discovery._specVersion = 'swagger_2';
711
+ const baseUrl = discovery._extractBaseUrl(spec);
712
+ expect(baseUrl).toBe('http://api.example.com/api');
713
+ });
714
+ it('should default to https when no schemes', () => {
715
+ const discovery = new OCPSchemaDiscovery();
716
+ const spec = {
717
+ swagger: '2.0',
718
+ host: 'api.example.com',
719
+ basePath: '/v2'
720
+ };
721
+ discovery._specVersion = 'swagger_2';
722
+ const baseUrl = discovery._extractBaseUrl(spec);
723
+ expect(baseUrl).toBe('https://api.example.com/v2');
724
+ });
725
+ it('should parse Swagger 2.0 response schemas', () => {
726
+ const discovery = new OCPSchemaDiscovery();
727
+ discovery._specVersion = 'swagger_2';
728
+ const responses = swagger2Spec.paths['/users'].get.responses;
729
+ const schema = discovery._parseResponses(responses, swagger2Spec, {});
730
+ expect(schema).not.toBeNull();
731
+ expect(schema.type).toBe('array');
732
+ expect(schema.items).toBeDefined();
733
+ expect(schema.items.type).toBe('object');
734
+ });
735
+ it('should parse Swagger 2.0 body parameters', () => {
736
+ const discovery = new OCPSchemaDiscovery();
737
+ discovery._specVersion = 'swagger_2';
738
+ const postOperation = swagger2Spec.paths['/users'].post;
739
+ const bodyParam = postOperation.parameters[0];
740
+ const params = discovery._parseSwagger2BodyParameter(bodyParam, swagger2Spec, {});
741
+ expect(params.name).toBeDefined();
742
+ expect(params.email).toBeDefined();
743
+ expect(params.name.type).toBe('string');
744
+ expect(params.name.required).toBe(true);
745
+ expect(params.name.location).toBe('body');
746
+ expect(params.email.required).toBe(true);
747
+ });
748
+ it('should discover full API with Swagger 2.0 spec', async () => {
749
+ global.fetch = jest.fn(() => Promise.resolve({
750
+ ok: true,
751
+ json: async () => swagger2Spec,
752
+ }));
753
+ const discovery = new OCPSchemaDiscovery();
754
+ const apiSpec = await discovery.discoverApi('https://api.example.com/swagger.json');
755
+ expect(apiSpec.title).toBe('Swagger 2.0 API');
756
+ expect(apiSpec.version).toBe('1.0.0');
757
+ expect(apiSpec.base_url).toBe('https://api.example.com/v1');
758
+ expect(apiSpec.tools.length).toBe(3);
759
+ // Check GET /users
760
+ const getUsers = apiSpec.tools.find(t => t.name === 'getUsers');
761
+ expect(getUsers).toBeDefined();
762
+ expect(getUsers.method).toBe('GET');
763
+ expect(getUsers.path).toBe('/users');
764
+ expect(getUsers.parameters.limit).toBeDefined();
765
+ expect(getUsers.response_schema).toBeDefined();
766
+ expect(getUsers.response_schema.type).toBe('array');
767
+ // Check POST /users
768
+ const postUsers = apiSpec.tools.find(t => t.name === 'createUser');
769
+ expect(postUsers).toBeDefined();
770
+ expect(postUsers.method).toBe('POST');
771
+ expect(postUsers.path).toBe('/users');
772
+ expect(postUsers.parameters.name).toBeDefined();
773
+ expect(postUsers.parameters.email).toBeDefined();
774
+ expect(postUsers.parameters.name.required).toBe(true);
775
+ expect(postUsers.response_schema).toBeDefined();
776
+ // Check GET /users/{id}
777
+ const getUser = apiSpec.tools.find(t => t.name === 'getUserById');
778
+ expect(getUser).toBeDefined();
779
+ expect(getUser.method).toBe('GET');
780
+ expect(getUser.path).toBe('/users/{id}');
781
+ expect(getUser.parameters.id).toBeDefined();
782
+ expect(getUser.parameters.id.location).toBe('path');
783
+ expect(getUser.response_schema).toBeDefined();
784
+ });
785
+ });
786
+ describe('Resource Filtering', () => {
787
+ const openApiSpecWithResources = {
788
+ openapi: '3.0.0',
789
+ info: { title: 'GitHub API', version: '3.0' },
790
+ servers: [{ url: 'https://api.github.com' }],
791
+ paths: {
792
+ '/repos/{owner}/{repo}': {
793
+ get: {
794
+ operationId: 'repos/get',
795
+ summary: 'Get a repository',
796
+ parameters: [
797
+ { name: 'owner', in: 'path', required: true, schema: { type: 'string' } },
798
+ { name: 'repo', in: 'path', required: true, schema: { type: 'string' } }
799
+ ],
800
+ responses: { '200': { description: 'Repository details' } }
801
+ }
802
+ },
803
+ '/user/repos': {
804
+ get: {
805
+ operationId: 'repos/listForAuthenticatedUser',
806
+ summary: 'List user repositories',
807
+ responses: { '200': { description: 'List of repositories' } }
808
+ }
809
+ },
810
+ '/repos/{owner}/{repo}/issues': {
811
+ get: {
812
+ operationId: 'issues/listForRepo',
813
+ summary: 'List repository issues',
814
+ parameters: [
815
+ { name: 'owner', in: 'path', required: true, schema: { type: 'string' } },
816
+ { name: 'repo', in: 'path', required: true, schema: { type: 'string' } }
817
+ ],
818
+ responses: { '200': { description: 'List of issues' } }
819
+ }
820
+ },
821
+ '/orgs/{org}/members': {
822
+ get: {
823
+ operationId: 'orgs/listMembers',
824
+ summary: 'List organization members',
825
+ parameters: [
826
+ { name: 'org', in: 'path', required: true, schema: { type: 'string' } }
827
+ ],
828
+ responses: { '200': { description: 'List of members' } }
829
+ }
830
+ }
831
+ }
832
+ };
833
+ const toolsWithResources = [
834
+ {
835
+ name: 'reposGet',
836
+ description: 'Get a repository',
837
+ method: 'GET',
838
+ path: '/repos/{owner}/{repo}',
839
+ parameters: {},
840
+ operation_id: 'repos/get',
841
+ tags: ['repos']
842
+ },
843
+ {
844
+ name: 'reposListForAuthenticatedUser',
845
+ description: 'List user repositories',
846
+ method: 'GET',
847
+ path: '/user/repos',
848
+ parameters: {},
849
+ operation_id: 'repos/listForAuthenticatedUser',
850
+ tags: ['repos']
851
+ },
852
+ {
853
+ name: 'issuesListForRepo',
854
+ description: 'List repository issues',
855
+ method: 'GET',
856
+ path: '/repos/{owner}/{repo}/issues',
857
+ parameters: {},
858
+ operation_id: 'issues/listForRepo',
859
+ tags: ['issues']
860
+ },
861
+ {
862
+ name: 'orgsListMembers',
863
+ description: 'List organization members',
864
+ method: 'GET',
865
+ path: '/orgs/{org}/members',
866
+ parameters: {},
867
+ operation_id: 'orgs/listMembers',
868
+ tags: ['orgs']
869
+ }
870
+ ];
871
+ test('_filterToolsByResources with single resource', () => {
872
+ const filtered = discovery._filterToolsByResources(toolsWithResources, ['repos']);
873
+ expect(filtered.length).toBe(2); // /repos/{owner}/{repo}, /repos/{owner}/{repo}/issues (NOT /user/repos)
874
+ const paths = new Set(filtered.map((tool) => tool.path));
875
+ expect(paths.has('/repos/{owner}/{repo}')).toBe(true);
876
+ expect(paths.has('/repos/{owner}/{repo}/issues')).toBe(true);
877
+ });
878
+ test('_filterToolsByResources with multiple resources', () => {
879
+ const filtered = discovery._filterToolsByResources(toolsWithResources, ['repos', 'orgs']);
880
+ expect(filtered.length).toBe(3); // /repos/..., /repos/.../issues, /orgs/... (NOT /user/repos)
881
+ });
882
+ test('_filterToolsByResources case insensitive', () => {
883
+ const filtered = discovery._filterToolsByResources(toolsWithResources, ['REPOS', 'Orgs']);
884
+ expect(filtered.length).toBe(3);
885
+ });
886
+ test('_filterToolsByResources with no matches', () => {
887
+ const filtered = discovery._filterToolsByResources(toolsWithResources, ['payments', 'customers']);
888
+ expect(filtered.length).toBe(0);
889
+ });
890
+ test('_filterToolsByResources with empty includeResources', () => {
891
+ const filtered = discovery._filterToolsByResources(toolsWithResources, []);
892
+ expect(filtered.length).toBe(4);
893
+ expect(filtered).toEqual(toolsWithResources);
894
+ });
895
+ test('_filterToolsByResources with undefined includeResources', () => {
896
+ const filtered = discovery._filterToolsByResources(toolsWithResources, undefined);
897
+ expect(filtered.length).toBe(4);
898
+ expect(filtered).toEqual(toolsWithResources);
899
+ });
900
+ test('_filterToolsByResources exact match', () => {
901
+ const tools = [
902
+ { name: 'listPaymentMethods', description: 'List payment methods', method: 'GET', path: '/payment_methods', parameters: {} },
903
+ { name: 'createPaymentIntent', description: 'Create payment intent', method: 'POST', path: '/payment_intents', parameters: {} },
904
+ { name: 'listPayments', description: 'List payments', method: 'GET', path: '/payments', parameters: {} }
905
+ ];
906
+ // Filter for "payment" should not match any (no exact segment match)
907
+ const filtered1 = discovery._filterToolsByResources(tools, ['payment']);
908
+ expect(filtered1.length).toBe(0); // "payment" doesn't exactly match any first segment
909
+ // Filter for "payments" should match the exact first segment
910
+ const filtered2 = discovery._filterToolsByResources(tools, ['payments']);
911
+ expect(filtered2.length).toBe(1);
912
+ expect(filtered2[0].path).toBe('/payments');
913
+ // Filter for "payment_methods" should match
914
+ const filtered3 = discovery._filterToolsByResources(tools, ['payment_methods']);
915
+ expect(filtered3.length).toBe(1);
916
+ expect(filtered3[0].path).toBe('/payment_methods');
917
+ });
918
+ test('_filterToolsByResources with dots', () => {
919
+ const tools = [
920
+ { name: 'conversationsReplies', description: 'Get conversation replies', method: 'GET', path: '/conversations.replies', parameters: {} },
921
+ { name: 'conversationsHistory', description: 'Get conversation history', method: 'GET', path: '/conversations.history', parameters: {} },
922
+ { name: 'chatPostMessage', description: 'Post a message', method: 'POST', path: '/chat.postMessage', parameters: {} }
923
+ ];
924
+ // Filter for "conversations" should match both conversation endpoints
925
+ const filtered1 = discovery._filterToolsByResources(tools, ['conversations']);
926
+ expect(filtered1.length).toBe(2);
927
+ expect(filtered1.every((tool) => tool.path.includes('conversations'))).toBe(true);
928
+ // Filter for "chat" should match the chat endpoint
929
+ const filtered2 = discovery._filterToolsByResources(tools, ['chat']);
930
+ expect(filtered2.length).toBe(1);
931
+ expect(filtered2[0].path).toBe('/chat.postMessage');
932
+ });
933
+ test('_filterToolsByResources no substring match', () => {
934
+ const tools = [
935
+ { name: 'listRepos', description: 'List repos', method: 'GET', path: '/repos/{owner}/{repo}', parameters: {} },
936
+ { name: 'listRepositories', description: 'List enterprise repositories', method: 'GET',
937
+ path: '/enterprises/{enterprise}/code-security/configurations/{config_id}/repositories', parameters: {} }
938
+ ];
939
+ // Filter for "repos" should match "/repos/{owner}/{repo}"
940
+ // Should NOT match "/enterprises/.../repositories" (repos != repositories)
941
+ const filtered1 = discovery._filterToolsByResources(tools, ['repos']);
942
+ expect(filtered1.length).toBe(1);
943
+ expect(filtered1[0].path).toBe('/repos/{owner}/{repo}');
944
+ // Filter for "repositories" should not match (first segment is "enterprises")
945
+ const filtered2 = discovery._filterToolsByResources(tools, ['repositories']);
946
+ expect(filtered2.length).toBe(0);
947
+ // Filter for "enterprises" should match the enterprise endpoint
948
+ const filtered3 = discovery._filterToolsByResources(tools, ['enterprises']);
949
+ expect(filtered3.length).toBe(1);
950
+ expect(filtered3[0].path.includes('/enterprises')).toBe(true);
951
+ });
952
+ test('_filterToolsByResources with path prefix', () => {
953
+ const tools = [
954
+ { name: 'listPayments', description: 'List payments', method: 'GET', path: '/v1/payments', parameters: {} },
955
+ { name: 'createCharge', description: 'Create charge', method: 'POST', path: '/v1/charges', parameters: {} },
956
+ { name: 'legacyPayment', description: 'Legacy payment', method: 'GET', path: '/v2/payments', parameters: {} }
957
+ ];
958
+ // Filter for "payments" with /v1 prefix
959
+ const filtered1 = discovery._filterToolsByResources(tools, ['payments'], '/v1');
960
+ expect(filtered1.length).toBe(1);
961
+ expect(filtered1[0].path).toBe('/v1/payments');
962
+ // Filter for "payments" with /v2 prefix
963
+ const filtered2 = discovery._filterToolsByResources(tools, ['payments'], '/v2');
964
+ expect(filtered2.length).toBe(1);
965
+ expect(filtered2[0].path).toBe('/v2/payments');
966
+ // Filter without prefix - no matches (first segment is "v1" or "v2")
967
+ const filtered3 = discovery._filterToolsByResources(tools, ['payments']);
968
+ expect(filtered3.length).toBe(0);
969
+ });
970
+ test('_filterToolsByResources first segment only', () => {
971
+ const tools = [
972
+ { name: 'listRepoIssues', description: 'List repo issues', method: 'GET', path: '/repos/{owner}/{repo}/issues', parameters: {} },
973
+ { name: 'listUserRepos', description: 'List user repos', method: 'GET', path: '/user/repos', parameters: {} }
974
+ ];
975
+ // Filter for "repos" - should match /repos/... but NOT /user/repos (first segment is "user")
976
+ const filtered1 = discovery._filterToolsByResources(tools, ['repos']);
977
+ expect(filtered1.length).toBe(1);
978
+ expect(filtered1[0].path).toBe('/repos/{owner}/{repo}/issues');
979
+ // Filter for "user" - should match /user/repos
980
+ const filtered2 = discovery._filterToolsByResources(tools, ['user']);
981
+ expect(filtered2.length).toBe(1);
982
+ expect(filtered2[0].path).toBe('/user/repos');
983
+ // Filter for "issues" - should NOT match anything (issues is not first segment)
984
+ const filtered3 = discovery._filterToolsByResources(tools, ['issues']);
985
+ expect(filtered3.length).toBe(0);
986
+ });
987
+ test('discoverApi with includeResources parameter', async () => {
988
+ const mockFetch = global.fetch;
989
+ mockFetch.mockResolvedValueOnce({
990
+ ok: true,
991
+ json: async () => openApiSpecWithResources,
992
+ });
993
+ const apiSpec = await discovery.discoverApi('https://api.github.com/openapi.json', undefined, ['repos']);
994
+ expect(apiSpec.tools.length).toBe(2);
995
+ expect(apiSpec.tools.every(tool => tool.path.toLowerCase().startsWith('/repos'))).toBe(true);
996
+ });
997
+ test('discoverApi with multiple includeResources', async () => {
998
+ const mockFetch = global.fetch;
999
+ mockFetch.mockResolvedValueOnce({
1000
+ ok: true,
1001
+ json: async () => openApiSpecWithResources,
1002
+ });
1003
+ const apiSpec = await discovery.discoverApi('https://api.github.com/openapi.json', undefined, ['repos', 'orgs']);
1004
+ expect(apiSpec.tools.length).toBe(3);
1005
+ });
1006
+ test('discoverApi without includeResources returns all tools', async () => {
1007
+ const mockFetch = global.fetch;
1008
+ mockFetch.mockResolvedValueOnce({
1009
+ ok: true,
1010
+ json: async () => openApiSpecWithResources,
1011
+ });
1012
+ const apiSpec = await discovery.discoverApi('https://api.github.com/openapi.json');
1013
+ expect(apiSpec.tools.length).toBe(4);
1014
+ });
1015
+ });
1016
+ describe('Local File Loading', () => {
1017
+ test('load spec from absolute path (JSON)', async () => {
1018
+ const absolutePath = `${__dirname}/fixtures/test_spec.json`;
1019
+ const apiSpec = await discovery.discoverApi(absolutePath);
1020
+ expect(apiSpec.title).toBe('Test API from File');
1021
+ expect(apiSpec.version).toBe('1.0.0');
1022
+ expect(apiSpec.base_url).toBe('https://api.example.com');
1023
+ expect(apiSpec.tools.length).toBe(1);
1024
+ expect(apiSpec.tools[0].name).toBe('getTest');
1025
+ });
1026
+ test('load spec from relative path (JSON)', async () => {
1027
+ const relativePath = './tests/fixtures/test_spec.json';
1028
+ const apiSpec = await discovery.discoverApi(relativePath);
1029
+ expect(apiSpec.title).toBe('Test API from File');
1030
+ expect(apiSpec.tools.length).toBe(1);
1031
+ });
1032
+ test('load spec from absolute path (YAML)', async () => {
1033
+ const absolutePath = `${__dirname}/fixtures/test_spec.yaml`;
1034
+ const apiSpec = await discovery.discoverApi(absolutePath);
1035
+ expect(apiSpec.title).toBe('Test API from File');
1036
+ expect(apiSpec.version).toBe('1.0.0');
1037
+ expect(apiSpec.base_url).toBe('https://api.example.com');
1038
+ expect(apiSpec.tools.length).toBe(1);
1039
+ expect(apiSpec.tools[0].name).toBe('getTest');
1040
+ });
1041
+ test('load spec from relative path (YAML)', async () => {
1042
+ const relativePath = './tests/fixtures/test_spec.yaml';
1043
+ const apiSpec = await discovery.discoverApi(relativePath);
1044
+ expect(apiSpec.title).toBe('Test API from File');
1045
+ expect(apiSpec.tools.length).toBe(1);
1046
+ });
1047
+ test('error on file not found', async () => {
1048
+ await expect(discovery.discoverApi('./nonexistent.json')).rejects.toThrow('File not found');
1049
+ });
1050
+ test('error on unsupported file format', async () => {
1051
+ await expect(discovery.discoverApi('./some/file.txt')).rejects.toThrow('Unsupported file format');
1052
+ });
1053
+ test('error on invalid JSON', async () => {
1054
+ const invalidJsonPath = `${__dirname}/fixtures/invalid.json`;
1055
+ await expect(discovery.discoverApi(invalidJsonPath)).rejects.toThrow('Invalid JSON');
1056
+ });
1057
+ test('error on invalid YAML', async () => {
1058
+ const invalidYamlPath = `${__dirname}/fixtures/invalid.yaml`;
1059
+ await expect(discovery.discoverApi(invalidYamlPath)).rejects.toThrow('Invalid YAML');
1060
+ });
1061
+ test('cache normalization for file paths', async () => {
1062
+ // Load via absolute path
1063
+ const absolutePath = `${__dirname}/fixtures/test_spec.json`;
1064
+ const apiSpec1 = await discovery.discoverApi(absolutePath);
1065
+ // Load via relative path (should hit cache)
1066
+ const relativePath = './tests/fixtures/test_spec.json';
1067
+ const apiSpec2 = await discovery.discoverApi(relativePath);
1068
+ // Both should resolve to same spec (from cache)
1069
+ expect(apiSpec1.name).toBe(apiSpec2.name);
1070
+ expect(apiSpec1.tools.length).toBe(apiSpec2.tools.length);
1071
+ });
1072
+ });
1073
+ });
1074
+ //# sourceMappingURL=schema_discovery.test.js.map