mcp-openapi-schema-explorer 1.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.
- package/.devcontainer/devcontainer.json +24 -0
- package/.github/dependabot.yml +13 -0
- package/.github/workflows/ci.yml +111 -0
- package/.husky/pre-commit +6 -0
- package/.prettierignore +3 -0
- package/.prettierrc.json +12 -0
- package/.releaserc.json +23 -0
- package/CHANGELOG.md +32 -0
- package/CONTRIBUTING.md +67 -0
- package/Dockerfile +3 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/src/config.d.ts +15 -0
- package/dist/src/config.js +19 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/handlers/component-detail-handler.d.ts +14 -0
- package/dist/src/handlers/component-detail-handler.js +87 -0
- package/dist/src/handlers/component-detail-handler.js.map +1 -0
- package/dist/src/handlers/component-map-handler.d.ts +14 -0
- package/dist/src/handlers/component-map-handler.js +63 -0
- package/dist/src/handlers/component-map-handler.js.map +1 -0
- package/dist/src/handlers/handler-utils.d.ts +69 -0
- package/dist/src/handlers/handler-utils.js +180 -0
- package/dist/src/handlers/handler-utils.js.map +1 -0
- package/dist/src/handlers/operation-handler.d.ts +14 -0
- package/dist/src/handlers/operation-handler.js +86 -0
- package/dist/src/handlers/operation-handler.js.map +1 -0
- package/dist/src/handlers/path-item-handler.d.ts +14 -0
- package/dist/src/handlers/path-item-handler.js +66 -0
- package/dist/src/handlers/path-item-handler.js.map +1 -0
- package/dist/src/handlers/top-level-field-handler.d.ts +14 -0
- package/dist/src/handlers/top-level-field-handler.js +72 -0
- package/dist/src/handlers/top-level-field-handler.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +177 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/rendering/components.d.ts +67 -0
- package/dist/src/rendering/components.js +177 -0
- package/dist/src/rendering/components.js.map +1 -0
- package/dist/src/rendering/document.d.ts +36 -0
- package/dist/src/rendering/document.js +147 -0
- package/dist/src/rendering/document.js.map +1 -0
- package/dist/src/rendering/path-item.d.ts +45 -0
- package/dist/src/rendering/path-item.js +141 -0
- package/dist/src/rendering/path-item.js.map +1 -0
- package/dist/src/rendering/paths.d.ts +26 -0
- package/dist/src/rendering/paths.js +78 -0
- package/dist/src/rendering/paths.js.map +1 -0
- package/dist/src/rendering/types.d.ts +50 -0
- package/dist/src/rendering/types.js +12 -0
- package/dist/src/rendering/types.js.map +1 -0
- package/dist/src/rendering/utils.d.ts +31 -0
- package/dist/src/rendering/utils.js +79 -0
- package/dist/src/rendering/utils.js.map +1 -0
- package/dist/src/services/formatters.d.ts +36 -0
- package/dist/src/services/formatters.js +52 -0
- package/dist/src/services/formatters.js.map +1 -0
- package/dist/src/services/reference-transform.d.ts +27 -0
- package/dist/src/services/reference-transform.js +75 -0
- package/dist/src/services/reference-transform.js.map +1 -0
- package/dist/src/services/spec-loader.d.ts +27 -0
- package/dist/src/services/spec-loader.js +77 -0
- package/dist/src/services/spec-loader.js.map +1 -0
- package/dist/src/types.d.ts +11 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/uri-builder.d.ts +81 -0
- package/dist/src/utils/uri-builder.js +121 -0
- package/dist/src/utils/uri-builder.js.map +1 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +4 -0
- package/dist/src/version.js.map +1 -0
- package/eslint.config.js +88 -0
- package/jest.config.js +32 -0
- package/justfile +66 -0
- package/memory-bank/activeContext.md +139 -0
- package/memory-bank/productContext.md +39 -0
- package/memory-bank/progress.md +141 -0
- package/memory-bank/projectbrief.md +50 -0
- package/memory-bank/systemPatterns.md +224 -0
- package/memory-bank/techContext.md +131 -0
- package/package.json +76 -0
- package/scripts/generate-version.js +49 -0
- package/src/config.ts +33 -0
- package/src/handlers/component-detail-handler.ts +121 -0
- package/src/handlers/component-map-handler.ts +92 -0
- package/src/handlers/handler-utils.ts +230 -0
- package/src/handlers/operation-handler.ts +114 -0
- package/src/handlers/path-item-handler.ts +88 -0
- package/src/handlers/top-level-field-handler.ts +92 -0
- package/src/index.ts +222 -0
- package/src/rendering/components.ts +228 -0
- package/src/rendering/document.ts +167 -0
- package/src/rendering/path-item.ts +157 -0
- package/src/rendering/paths.ts +87 -0
- package/src/rendering/types.ts +63 -0
- package/src/rendering/utils.ts +107 -0
- package/src/services/formatters.ts +71 -0
- package/src/services/reference-transform.ts +105 -0
- package/src/services/spec-loader.ts +88 -0
- package/src/types.ts +17 -0
- package/src/utils/uri-builder.ts +134 -0
- package/src/version.ts +4 -0
- package/test/__tests__/e2e/format.test.ts +224 -0
- package/test/__tests__/e2e/resources.test.ts +369 -0
- package/test/__tests__/e2e/spec-loading.test.ts +172 -0
- package/test/__tests__/unit/config.test.ts +39 -0
- package/test/__tests__/unit/handlers/component-detail-handler.test.ts +241 -0
- package/test/__tests__/unit/handlers/component-map-handler.test.ts +187 -0
- package/test/__tests__/unit/handlers/handler-utils.test.ts +255 -0
- package/test/__tests__/unit/handlers/operation-handler.test.ts +202 -0
- package/test/__tests__/unit/handlers/path-item-handler.test.ts +153 -0
- package/test/__tests__/unit/handlers/top-level-field-handler.test.ts +182 -0
- package/test/__tests__/unit/rendering/components.test.ts +269 -0
- package/test/__tests__/unit/rendering/document.test.ts +172 -0
- package/test/__tests__/unit/rendering/path-item.test.ts +197 -0
- package/test/__tests__/unit/rendering/paths.test.ts +115 -0
- package/test/__tests__/unit/services/formatters.test.ts +109 -0
- package/test/__tests__/unit/services/reference-transform.test.ts +320 -0
- package/test/__tests__/unit/services/spec-loader.test.ts +214 -0
- package/test/__tests__/unit/utils/uri-builder.test.ts +103 -0
- package/test/fixtures/complex-endpoint.json +146 -0
- package/test/fixtures/empty-api.json +8 -0
- package/test/fixtures/multi-component-types.json +55 -0
- package/test/fixtures/paths-test.json +61 -0
- package/test/fixtures/sample-api.json +68 -0
- package/test/fixtures/sample-v2-api.json +39 -0
- package/test/setup.ts +32 -0
- package/test/utils/console-helpers.ts +48 -0
- package/test/utils/mcp-test-helpers.ts +66 -0
- package/test/utils/test-types.ts +54 -0
- package/tsconfig.json +25 -0
- package/tsconfig.test.json +5 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
// Import specific SDK types needed
|
|
3
|
+
import {
|
|
4
|
+
ReadResourceResult,
|
|
5
|
+
TextResourceContents,
|
|
6
|
+
// Removed unused CompleteRequest, CompleteResult
|
|
7
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { startMcpServer, McpTestContext } from '../../utils/mcp-test-helpers';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
|
|
11
|
+
// Use the complex spec for E2E tests
|
|
12
|
+
const complexSpecPath = path.resolve(__dirname, '../../fixtures/complex-endpoint.json');
|
|
13
|
+
|
|
14
|
+
// Helper function to parse JSON safely
|
|
15
|
+
function parseJsonSafely(text: string | undefined): unknown {
|
|
16
|
+
if (text === undefined) {
|
|
17
|
+
throw new Error('Received undefined text for JSON parsing');
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(text);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error('Failed to parse JSON:', text);
|
|
23
|
+
throw new Error(`Invalid JSON received: ${e instanceof Error ? e.message : String(e)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Type guard to check if content is TextResourceContents
|
|
28
|
+
function hasTextContent(
|
|
29
|
+
content: ReadResourceResult['contents'][0]
|
|
30
|
+
): content is TextResourceContents {
|
|
31
|
+
// Check for the 'text' property specifically, differentiating from BlobResourceContents
|
|
32
|
+
return typeof (content as TextResourceContents).text === 'string';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('E2E Tests for Refactored Resources', () => {
|
|
36
|
+
let testContext: McpTestContext;
|
|
37
|
+
let client: Client; // Use the correct Client type
|
|
38
|
+
|
|
39
|
+
// Helper to setup client for tests
|
|
40
|
+
async function setup(specPath: string = complexSpecPath): Promise<void> {
|
|
41
|
+
// Use complex spec by default
|
|
42
|
+
testContext = await startMcpServer(specPath, { outputFormat: 'json' }); // Default to JSON
|
|
43
|
+
client = testContext.client; // Get client from helper context
|
|
44
|
+
// Initialization is handled by startMcpServer connecting the transport
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
await testContext?.cleanup(); // Use cleanup function from helper
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Helper to read resource and perform basic checks
|
|
52
|
+
async function readResourceAndCheck(uri: string): Promise<ReadResourceResult['contents'][0]> {
|
|
53
|
+
const result = await client.readResource({ uri });
|
|
54
|
+
expect(result.contents).toHaveLength(1);
|
|
55
|
+
const content = result.contents[0];
|
|
56
|
+
expect(content.uri).toBe(uri);
|
|
57
|
+
return content;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Helper to read resource and check for text/plain list content
|
|
61
|
+
async function checkTextListResponse(uri: string, expectedSubstrings: string[]): Promise<string> {
|
|
62
|
+
const content = await readResourceAndCheck(uri);
|
|
63
|
+
expect(content.mimeType).toBe('text/plain');
|
|
64
|
+
expect(content.isError).toBeFalsy();
|
|
65
|
+
if (!hasTextContent(content)) throw new Error('Expected text content');
|
|
66
|
+
for (const sub of expectedSubstrings) {
|
|
67
|
+
expect(content.text).toContain(sub);
|
|
68
|
+
}
|
|
69
|
+
return content.text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Helper to read resource and check for JSON detail content
|
|
73
|
+
async function checkJsonDetailResponse(uri: string, expectedObject: object): Promise<unknown> {
|
|
74
|
+
const content = await readResourceAndCheck(uri);
|
|
75
|
+
expect(content.mimeType).toBe('application/json');
|
|
76
|
+
expect(content.isError).toBeFalsy();
|
|
77
|
+
if (!hasTextContent(content)) throw new Error('Expected text content');
|
|
78
|
+
const data = parseJsonSafely(content.text);
|
|
79
|
+
expect(data).toMatchObject(expectedObject);
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Helper to read resource and check for error
|
|
84
|
+
async function checkErrorResponse(uri: string, expectedErrorText: string): Promise<void> {
|
|
85
|
+
const content = await readResourceAndCheck(uri);
|
|
86
|
+
expect(content.isError).toBe(true);
|
|
87
|
+
expect(content.mimeType).toBe('text/plain'); // Errors are plain text
|
|
88
|
+
if (!hasTextContent(content)) throw new Error('Expected text content for error');
|
|
89
|
+
expect(content.text).toContain(expectedErrorText);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('openapi://{field}', () => {
|
|
93
|
+
beforeEach(async () => await setup());
|
|
94
|
+
|
|
95
|
+
it('should retrieve the "info" field', async () => {
|
|
96
|
+
// Matches complex-endpoint.json
|
|
97
|
+
await checkJsonDetailResponse('openapi://info', {
|
|
98
|
+
title: 'Complex Endpoint Test API',
|
|
99
|
+
version: '1.0.0',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should retrieve the "paths" list', async () => {
|
|
104
|
+
// Matches complex-endpoint.json
|
|
105
|
+
await checkTextListResponse('openapi://paths', [
|
|
106
|
+
'Hint:',
|
|
107
|
+
'GET POST /api/v1/organizations/{orgId}/projects/{projectId}/tasks',
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should retrieve the "components" list', async () => {
|
|
112
|
+
// Matches complex-endpoint.json (only has schemas)
|
|
113
|
+
await checkTextListResponse('openapi://components', [
|
|
114
|
+
'Available Component Types:',
|
|
115
|
+
'- schemas',
|
|
116
|
+
"Hint: Use 'openapi://components/{type}'",
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should return error for invalid field', async () => {
|
|
121
|
+
const uri = 'openapi://invalidfield';
|
|
122
|
+
await checkErrorResponse(uri, 'Field "invalidfield" not found');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('openapi://paths/{path}', () => {
|
|
127
|
+
beforeEach(async () => await setup());
|
|
128
|
+
|
|
129
|
+
it('should list methods for the complex task path', async () => {
|
|
130
|
+
const complexPath = 'api/v1/organizations/{orgId}/projects/{projectId}/tasks';
|
|
131
|
+
const encodedPath = encodeURIComponent(complexPath);
|
|
132
|
+
// Update expected format based on METHOD: Summary/OpId
|
|
133
|
+
await checkTextListResponse(`openapi://paths/${encodedPath}`, [
|
|
134
|
+
"Hint: Use 'openapi://paths/api%2Fv1%2Forganizations%2F%7BorgId%7D%2Fprojects%2F%7BprojectId%7D%2Ftasks/{method}'", // Hint comes first now
|
|
135
|
+
'', // Blank line after hint
|
|
136
|
+
'GET: Get Tasks', // METHOD: summary
|
|
137
|
+
'POST: Create Task', // METHOD: summary
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should return error for non-existent path', async () => {
|
|
142
|
+
const encodedPath = encodeURIComponent('nonexistent');
|
|
143
|
+
const uri = `openapi://paths/${encodedPath}`;
|
|
144
|
+
// Updated error message from getValidatedPathItem
|
|
145
|
+
await checkErrorResponse(uri, 'Path "/nonexistent" not found in the specification.');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('openapi://paths/{path}/{method*}', () => {
|
|
150
|
+
beforeEach(async () => await setup());
|
|
151
|
+
|
|
152
|
+
it('should get details for GET on complex path', async () => {
|
|
153
|
+
const complexPath = 'api/v1/organizations/{orgId}/projects/{projectId}/tasks';
|
|
154
|
+
const encodedPath = encodeURIComponent(complexPath);
|
|
155
|
+
// Check operationId from complex-endpoint.json
|
|
156
|
+
await checkJsonDetailResponse(`openapi://paths/${encodedPath}/get`, {
|
|
157
|
+
operationId: 'getProjectTasks',
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should get details for multiple methods GET,POST on complex path', async () => {
|
|
162
|
+
const complexPath = 'api/v1/organizations/{orgId}/projects/{projectId}/tasks';
|
|
163
|
+
const encodedPath = encodeURIComponent(complexPath);
|
|
164
|
+
const result = await client.readResource({ uri: `openapi://paths/${encodedPath}/get,post` });
|
|
165
|
+
expect(result.contents).toHaveLength(2);
|
|
166
|
+
|
|
167
|
+
const getContent = result.contents.find(c => c.uri.endsWith('/get'));
|
|
168
|
+
expect(getContent).toBeDefined();
|
|
169
|
+
expect(getContent?.isError).toBeFalsy();
|
|
170
|
+
if (!getContent || !hasTextContent(getContent))
|
|
171
|
+
throw new Error('Expected text content for GET');
|
|
172
|
+
const getData = parseJsonSafely(getContent.text);
|
|
173
|
+
// Check operationId from complex-endpoint.json
|
|
174
|
+
expect(getData).toMatchObject({ operationId: 'getProjectTasks' });
|
|
175
|
+
|
|
176
|
+
const postContent = result.contents.find(c => c.uri.endsWith('/post'));
|
|
177
|
+
expect(postContent).toBeDefined();
|
|
178
|
+
expect(postContent?.isError).toBeFalsy();
|
|
179
|
+
if (!postContent || !hasTextContent(postContent))
|
|
180
|
+
throw new Error('Expected text content for POST');
|
|
181
|
+
const postData = parseJsonSafely(postContent.text);
|
|
182
|
+
// Check operationId from complex-endpoint.json
|
|
183
|
+
expect(postData).toMatchObject({ operationId: 'createProjectTask' });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should return error for invalid method on complex path', async () => {
|
|
187
|
+
const complexPath = 'api/v1/organizations/{orgId}/projects/{projectId}/tasks';
|
|
188
|
+
const encodedPath = encodeURIComponent(complexPath);
|
|
189
|
+
const uri = `openapi://paths/${encodedPath}/put`;
|
|
190
|
+
// Updated error message from getValidatedOperations
|
|
191
|
+
await checkErrorResponse(
|
|
192
|
+
uri,
|
|
193
|
+
'None of the requested methods (put) are valid for path "/api/v1/organizations/{orgId}/projects/{projectId}/tasks". Available methods: get, post'
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('openapi://components/{type}', () => {
|
|
199
|
+
beforeEach(async () => await setup());
|
|
200
|
+
|
|
201
|
+
it('should list schemas', async () => {
|
|
202
|
+
// Matches complex-endpoint.json
|
|
203
|
+
await checkTextListResponse('openapi://components/schemas', [
|
|
204
|
+
'Available schemas:',
|
|
205
|
+
'- CreateTaskRequest',
|
|
206
|
+
'- Task',
|
|
207
|
+
'- TaskList',
|
|
208
|
+
"Hint: Use 'openapi://components/schemas/{name}'",
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should return error for invalid type', async () => {
|
|
213
|
+
const uri = 'openapi://components/invalid';
|
|
214
|
+
await checkErrorResponse(uri, 'Invalid component type: invalid');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('openapi://components/{type}/{name*}', () => {
|
|
219
|
+
beforeEach(async () => await setup());
|
|
220
|
+
|
|
221
|
+
it('should get details for schema Task', async () => {
|
|
222
|
+
// Matches complex-endpoint.json
|
|
223
|
+
await checkJsonDetailResponse('openapi://components/schemas/Task', {
|
|
224
|
+
type: 'object',
|
|
225
|
+
properties: { id: { type: 'string' }, title: { type: 'string' } },
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should get details for multiple schemas Task,TaskList', async () => {
|
|
230
|
+
// Matches complex-endpoint.json
|
|
231
|
+
const result = await client.readResource({
|
|
232
|
+
uri: 'openapi://components/schemas/Task,TaskList',
|
|
233
|
+
});
|
|
234
|
+
expect(result.contents).toHaveLength(2);
|
|
235
|
+
|
|
236
|
+
const taskContent = result.contents.find(c => c.uri.endsWith('/Task'));
|
|
237
|
+
expect(taskContent).toBeDefined();
|
|
238
|
+
expect(taskContent?.isError).toBeFalsy();
|
|
239
|
+
if (!taskContent || !hasTextContent(taskContent))
|
|
240
|
+
throw new Error('Expected text content for Task');
|
|
241
|
+
const taskData = parseJsonSafely(taskContent.text);
|
|
242
|
+
expect(taskData).toMatchObject({ properties: { id: { type: 'string' } } });
|
|
243
|
+
|
|
244
|
+
const taskListContent = result.contents.find(c => c.uri.endsWith('/TaskList'));
|
|
245
|
+
expect(taskListContent).toBeDefined();
|
|
246
|
+
expect(taskListContent?.isError).toBeFalsy();
|
|
247
|
+
if (!taskListContent || !hasTextContent(taskListContent))
|
|
248
|
+
throw new Error('Expected text content for TaskList');
|
|
249
|
+
const taskListData = parseJsonSafely(taskListContent.text);
|
|
250
|
+
expect(taskListData).toMatchObject({ properties: { items: { type: 'array' } } });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should return error for invalid name', async () => {
|
|
254
|
+
const uri = 'openapi://components/schemas/InvalidSchemaName';
|
|
255
|
+
// Updated error message from getValidatedComponentDetails with sorted names
|
|
256
|
+
await checkErrorResponse(
|
|
257
|
+
uri,
|
|
258
|
+
'None of the requested names (InvalidSchemaName) are valid for component type "schemas". Available names: CreateTaskRequest, Task, TaskList'
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Removed ListResourceTemplates test suite as the 'complete' property
|
|
264
|
+
// is likely not part of the standard response payload.
|
|
265
|
+
// We assume the templates are registered correctly in src/index.ts.
|
|
266
|
+
|
|
267
|
+
describe('Completion Tests', () => {
|
|
268
|
+
beforeEach(async () => await setup()); // Use the same setup
|
|
269
|
+
|
|
270
|
+
it('should provide completions for {field}', async () => {
|
|
271
|
+
const params = {
|
|
272
|
+
argument: { name: 'field', value: '' }, // Empty value to get all
|
|
273
|
+
ref: { type: 'ref/resource' as const, uri: 'openapi://{field}' },
|
|
274
|
+
};
|
|
275
|
+
const result = await client.complete(params);
|
|
276
|
+
expect(result.completion).toBeDefined();
|
|
277
|
+
expect(result.completion.values).toEqual(
|
|
278
|
+
expect.arrayContaining(['openapi', 'info', 'paths', 'components']) // Based on complex-endpoint.json
|
|
279
|
+
);
|
|
280
|
+
expect(result.completion.values).toHaveLength(4);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should provide completions for {path}', async () => {
|
|
284
|
+
const params = {
|
|
285
|
+
argument: { name: 'path', value: '' }, // Empty value to get all
|
|
286
|
+
ref: { type: 'ref/resource' as const, uri: 'openapi://paths/{path}' },
|
|
287
|
+
};
|
|
288
|
+
const result = await client.complete(params);
|
|
289
|
+
expect(result.completion).toBeDefined();
|
|
290
|
+
// Check for the encoded path from complex-endpoint.json
|
|
291
|
+
expect(result.completion.values).toEqual([
|
|
292
|
+
'api%2Fv1%2Forganizations%2F%7BorgId%7D%2Fprojects%2F%7BprojectId%7D%2Ftasks',
|
|
293
|
+
]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should provide completions for {method*}', async () => {
|
|
297
|
+
const params = {
|
|
298
|
+
argument: { name: 'method', value: '' }, // Empty value to get all
|
|
299
|
+
ref: {
|
|
300
|
+
type: 'ref/resource' as const,
|
|
301
|
+
uri: 'openapi://paths/{path}/{method*}', // Use the exact template URI
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
const result = await client.complete(params);
|
|
305
|
+
expect(result.completion).toBeDefined();
|
|
306
|
+
// Check for the static list of methods defined in src/index.ts
|
|
307
|
+
expect(result.completion.values).toEqual([
|
|
308
|
+
'GET',
|
|
309
|
+
'POST',
|
|
310
|
+
'PUT',
|
|
311
|
+
'DELETE',
|
|
312
|
+
'PATCH',
|
|
313
|
+
'OPTIONS',
|
|
314
|
+
'HEAD',
|
|
315
|
+
'TRACE',
|
|
316
|
+
]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should provide completions for {type}', async () => {
|
|
320
|
+
const params = {
|
|
321
|
+
argument: { name: 'type', value: '' }, // Empty value to get all
|
|
322
|
+
ref: { type: 'ref/resource' as const, uri: 'openapi://components/{type}' },
|
|
323
|
+
};
|
|
324
|
+
const result = await client.complete(params);
|
|
325
|
+
expect(result.completion).toBeDefined();
|
|
326
|
+
// Check for component types in complex-endpoint.json
|
|
327
|
+
expect(result.completion.values).toEqual(['schemas']);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Updated test for conditional name completion
|
|
331
|
+
it('should provide completions for {name*} when only one component type exists', async () => {
|
|
332
|
+
// complex-endpoint.json only has 'schemas'
|
|
333
|
+
const params = {
|
|
334
|
+
argument: { name: 'name', value: '' },
|
|
335
|
+
ref: {
|
|
336
|
+
type: 'ref/resource' as const,
|
|
337
|
+
uri: 'openapi://components/{type}/{name*}', // Use the exact template URI
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
const result = await client.complete(params);
|
|
341
|
+
expect(result.completion).toBeDefined();
|
|
342
|
+
// Expect schema names from complex-endpoint.json
|
|
343
|
+
expect(result.completion.values).toEqual(
|
|
344
|
+
expect.arrayContaining(['CreateTaskRequest', 'Task', 'TaskList'])
|
|
345
|
+
);
|
|
346
|
+
expect(result.completion.values).toHaveLength(3);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// New test for multiple component types
|
|
350
|
+
it('should NOT provide completions for {name*} when multiple component types exist', async () => {
|
|
351
|
+
// Need to restart the server with the multi-component spec
|
|
352
|
+
await testContext?.cleanup(); // Clean up previous server
|
|
353
|
+
const multiSpecPath = path.resolve(__dirname, '../../fixtures/multi-component-types.json');
|
|
354
|
+
await setup(multiSpecPath); // Restart server with new spec
|
|
355
|
+
|
|
356
|
+
const params = {
|
|
357
|
+
argument: { name: 'name', value: '' },
|
|
358
|
+
ref: {
|
|
359
|
+
type: 'ref/resource' as const,
|
|
360
|
+
uri: 'openapi://components/{type}/{name*}', // Use the exact template URI
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
const result = await client.complete(params);
|
|
364
|
+
expect(result.completion).toBeDefined();
|
|
365
|
+
// Expect empty array because multiple types (schemas, parameters) exist
|
|
366
|
+
expect(result.completion.values).toEqual([]);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { ReadResourceResult, TextResourceContents } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { startMcpServer, McpTestContext } from '../../utils/mcp-test-helpers';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
// Helper function to parse JSON safely
|
|
7
|
+
function parseJsonSafely(text: string | undefined): unknown {
|
|
8
|
+
if (text === undefined) {
|
|
9
|
+
throw new Error('Received undefined text for JSON parsing');
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(text);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
console.error('Failed to parse JSON:', text);
|
|
15
|
+
throw new Error(`Invalid JSON received: ${e instanceof Error ? e.message : String(e)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Type guard to check if content is TextResourceContents
|
|
20
|
+
function hasTextContent(
|
|
21
|
+
content: ReadResourceResult['contents'][0]
|
|
22
|
+
): content is TextResourceContents {
|
|
23
|
+
return typeof (content as TextResourceContents).text === 'string';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('E2E Tests for Spec Loading Scenarios', () => {
|
|
27
|
+
let testContext: McpTestContext | null = null; // Allow null for cleanup
|
|
28
|
+
let client: Client | null = null; // Allow null
|
|
29
|
+
|
|
30
|
+
// Helper to setup client for tests, allowing different spec paths
|
|
31
|
+
async function setup(specPathOrUrl: string): Promise<void> {
|
|
32
|
+
// Cleanup previous context if exists
|
|
33
|
+
if (testContext) {
|
|
34
|
+
await testContext.cleanup();
|
|
35
|
+
testContext = null;
|
|
36
|
+
client = null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
testContext = await startMcpServer(specPathOrUrl, { outputFormat: 'json' });
|
|
40
|
+
client = testContext.client;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// Explicitly convert error to string for logging
|
|
43
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
44
|
+
console.warn(`Skipping tests for ${specPathOrUrl} due to setup error: ${errorMsg}`);
|
|
45
|
+
testContext = null; // Ensure cleanup doesn't run on failed setup
|
|
46
|
+
client = null; // Ensure tests are skipped
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
await testContext?.cleanup();
|
|
52
|
+
testContext = null;
|
|
53
|
+
client = null;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Helper to read resource and perform basic checks
|
|
57
|
+
async function readResourceAndCheck(uri: string): Promise<ReadResourceResult['contents'][0]> {
|
|
58
|
+
if (!client) throw new Error('Client not initialized, skipping test.');
|
|
59
|
+
const result = await client.readResource({ uri });
|
|
60
|
+
expect(result.contents).toHaveLength(1);
|
|
61
|
+
const content = result.contents[0];
|
|
62
|
+
expect(content.uri).toBe(uri);
|
|
63
|
+
return content;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Helper to read resource and check for text/plain list content
|
|
67
|
+
async function checkTextListResponse(uri: string, expectedSubstrings: string[]): Promise<string> {
|
|
68
|
+
const content = await readResourceAndCheck(uri);
|
|
69
|
+
expect(content.mimeType).toBe('text/plain');
|
|
70
|
+
expect(content.isError).toBeFalsy();
|
|
71
|
+
if (!hasTextContent(content)) throw new Error('Expected text content');
|
|
72
|
+
for (const sub of expectedSubstrings) {
|
|
73
|
+
expect(content.text).toContain(sub);
|
|
74
|
+
}
|
|
75
|
+
return content.text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Helper to read resource and check for JSON detail content
|
|
79
|
+
async function checkJsonDetailResponse(uri: string, expectedObject: object): Promise<unknown> {
|
|
80
|
+
const content = await readResourceAndCheck(uri);
|
|
81
|
+
expect(content.mimeType).toBe('application/json');
|
|
82
|
+
expect(content.isError).toBeFalsy();
|
|
83
|
+
if (!hasTextContent(content)) throw new Error('Expected text content');
|
|
84
|
+
const data = parseJsonSafely(content.text);
|
|
85
|
+
expect(data).toMatchObject(expectedObject);
|
|
86
|
+
return data;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Tests for Local Swagger v2.0 Spec ---
|
|
90
|
+
describe('Local Swagger v2.0 Spec (sample-v2-api.json)', () => {
|
|
91
|
+
const v2SpecPath = path.resolve(__dirname, '../../fixtures/sample-v2-api.json');
|
|
92
|
+
|
|
93
|
+
beforeAll(async () => await setup(v2SpecPath)); // Use beforeAll for this block
|
|
94
|
+
|
|
95
|
+
it('should retrieve the converted "info" field', async () => {
|
|
96
|
+
if (!client) return; // Skip if setup failed
|
|
97
|
+
await checkJsonDetailResponse('openapi://info', {
|
|
98
|
+
title: 'Simple Swagger 2.0 API',
|
|
99
|
+
version: '1.0.0',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should retrieve the converted "paths" list', async () => {
|
|
104
|
+
if (!client) return; // Skip if setup failed
|
|
105
|
+
await checkTextListResponse('openapi://paths', [
|
|
106
|
+
'Hint:',
|
|
107
|
+
'GET /v2/ping', // Note the basePath is included
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should retrieve the converted "components" list', async () => {
|
|
112
|
+
if (!client) return; // Skip if setup failed
|
|
113
|
+
await checkTextListResponse('openapi://components', [
|
|
114
|
+
'Available Component Types:',
|
|
115
|
+
'- schemas',
|
|
116
|
+
"Hint: Use 'openapi://components/{type}'",
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should get details for converted schema Pong', async () => {
|
|
121
|
+
if (!client) return; // Skip if setup failed
|
|
122
|
+
await checkJsonDetailResponse('openapi://components/schemas/Pong', {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: { message: { type: 'string', example: 'pong' } },
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// --- Tests for Remote OpenAPI v3.0 Spec (Petstore) ---
|
|
130
|
+
// Increase timeout for remote fetch
|
|
131
|
+
jest.setTimeout(20000); // 20 seconds
|
|
132
|
+
|
|
133
|
+
describe('Remote OpenAPI v3.0 Spec (Petstore)', () => {
|
|
134
|
+
const petstoreUrl = 'https://petstore3.swagger.io/api/v3/openapi.json';
|
|
135
|
+
|
|
136
|
+
beforeAll(async () => await setup(petstoreUrl)); // Use beforeAll for this block
|
|
137
|
+
|
|
138
|
+
it('should retrieve the "info" field from Petstore', async () => {
|
|
139
|
+
if (!client) return; // Skip if setup failed
|
|
140
|
+
await checkJsonDetailResponse('openapi://info', {
|
|
141
|
+
title: 'Swagger Petstore - OpenAPI 3.0',
|
|
142
|
+
// version might change, so don't assert exact value
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should retrieve the "paths" list from Petstore', async () => {
|
|
147
|
+
if (!client) return; // Skip if setup failed
|
|
148
|
+
// Check for a known path
|
|
149
|
+
await checkTextListResponse('openapi://paths', ['/pet/{petId}']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should retrieve the "components" list from Petstore', async () => {
|
|
153
|
+
if (!client) return; // Skip if setup failed
|
|
154
|
+
// Check for known component types
|
|
155
|
+
await checkTextListResponse('openapi://components', [
|
|
156
|
+
'- schemas',
|
|
157
|
+
'- requestBodies',
|
|
158
|
+
'- securitySchemes',
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should get details for schema Pet from Petstore', async () => {
|
|
163
|
+
if (!client) return; // Skip if setup failed
|
|
164
|
+
await checkJsonDetailResponse('openapi://components/schemas/Pet', {
|
|
165
|
+
required: ['name', 'photoUrls'],
|
|
166
|
+
type: 'object',
|
|
167
|
+
// Check a known property
|
|
168
|
+
properties: { id: { type: 'integer', format: 'int64' } },
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { loadConfig } from '../../../src/config.js';
|
|
2
|
+
|
|
3
|
+
describe('Config', () => {
|
|
4
|
+
describe('loadConfig', () => {
|
|
5
|
+
it('returns valid configuration with default format when only path is provided', () => {
|
|
6
|
+
const config = loadConfig('/path/to/spec.json');
|
|
7
|
+
expect(config).toEqual({
|
|
8
|
+
specPath: '/path/to/spec.json',
|
|
9
|
+
outputFormat: 'json',
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns valid configuration when path and format are provided', () => {
|
|
14
|
+
const config = loadConfig('/path/to/spec.json', { outputFormat: 'yaml' });
|
|
15
|
+
expect(config).toEqual({
|
|
16
|
+
specPath: '/path/to/spec.json',
|
|
17
|
+
outputFormat: 'yaml',
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('throws error when invalid format is provided', () => {
|
|
22
|
+
expect(() => loadConfig('/path/to/spec.json', { outputFormat: 'invalid' })).toThrow(
|
|
23
|
+
'Invalid output format. Supported formats: json, yaml'
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('throws error when path is not provided', () => {
|
|
28
|
+
expect(() => loadConfig()).toThrow(
|
|
29
|
+
'OpenAPI spec path is required. Usage: npx mcp-openapi-schema-explorer <path-to-spec>'
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('throws error when path is empty string', () => {
|
|
34
|
+
expect(() => loadConfig('')).toThrow(
|
|
35
|
+
'OpenAPI spec path is required. Usage: npx mcp-openapi-schema-explorer <path-to-spec>'
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|