This is page 2 of 2. Use http://codebase.md/kadykov/mcp-openapi-schema-explorer?page={x} to view the full context.
# Directory Structure
```
├── .devcontainer
│ ├── devcontainer.json
│ └── Dockerfile
├── .github
│ ├── dependabot.yml
│ └── workflows
│ └── ci.yml
├── .gitignore
├── .husky
│ └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .releaserc.json
├── .tokeignore
├── assets
│ ├── logo-400.png
│ └── logo-full.png
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Dockerfile
├── DOCKERHUB_README.md
├── eslint.config.js
├── jest.config.js
├── justfile
├── LICENSE
├── llms-install.md
├── memory-bank
│ ├── activeContext.md
│ ├── productContext.md
│ ├── progress.md
│ ├── projectbrief.md
│ ├── systemPatterns.md
│ └── techContext.md
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ └── generate-version.js
├── src
│ ├── config.ts
│ ├── handlers
│ │ ├── component-detail-handler.ts
│ │ ├── component-map-handler.ts
│ │ ├── handler-utils.ts
│ │ ├── operation-handler.ts
│ │ ├── path-item-handler.ts
│ │ └── top-level-field-handler.ts
│ ├── index.ts
│ ├── rendering
│ │ ├── components.ts
│ │ ├── document.ts
│ │ ├── path-item.ts
│ │ ├── paths.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── services
│ │ ├── formatters.ts
│ │ ├── reference-transform.ts
│ │ └── spec-loader.ts
│ ├── types.ts
│ ├── utils
│ │ └── uri-builder.ts
│ └── version.ts
├── test
│ ├── __tests__
│ │ ├── e2e
│ │ │ ├── format.test.ts
│ │ │ ├── resources.test.ts
│ │ │ └── spec-loading.test.ts
│ │ └── unit
│ │ ├── config.test.ts
│ │ ├── handlers
│ │ │ ├── component-detail-handler.test.ts
│ │ │ ├── component-map-handler.test.ts
│ │ │ ├── handler-utils.test.ts
│ │ │ ├── operation-handler.test.ts
│ │ │ ├── path-item-handler.test.ts
│ │ │ └── top-level-field-handler.test.ts
│ │ ├── rendering
│ │ │ ├── components.test.ts
│ │ │ ├── document.test.ts
│ │ │ ├── path-item.test.ts
│ │ │ └── paths.test.ts
│ │ ├── services
│ │ │ ├── formatters.test.ts
│ │ │ ├── reference-transform.test.ts
│ │ │ └── spec-loader.test.ts
│ │ └── utils
│ │ └── uri-builder.test.ts
│ ├── fixtures
│ │ ├── complex-endpoint.json
│ │ ├── empty-api.json
│ │ ├── multi-component-types.json
│ │ ├── paths-test.json
│ │ ├── sample-api.json
│ │ └── sample-v2-api.json
│ ├── setup.ts
│ └── utils
│ ├── console-helpers.ts
│ ├── mcp-test-helpers.ts
│ └── test-types.ts
├── tsconfig.json
└── tsconfig.test.json
```
# Files
--------------------------------------------------------------------------------
/src/rendering/components.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIV3 } from 'openapi-types';
import { RenderableSpecObject, RenderContext, RenderResultItem } from './types.js'; // Add .js
import { createErrorResult, generateListHint } from './utils.js'; // Add .js
// Define valid component types based on OpenAPIV3.ComponentsObject keys
export type ComponentType = keyof OpenAPIV3.ComponentsObject;
export const VALID_COMPONENT_TYPES: ComponentType[] = [
'schemas',
'responses',
'parameters',
'examples',
'requestBodies',
'headers',
'securitySchemes',
'links',
'callbacks',
// 'pathItems' is technically allowed but we handle paths separately
];
// Simple descriptions for component types
const componentTypeDescriptions: Record<ComponentType, string> = {
schemas: 'Reusable data structures (models)',
responses: 'Reusable API responses',
parameters: 'Reusable request parameters (query, path, header, cookie)',
examples: 'Reusable examples of media type payloads',
requestBodies: 'Reusable request body definitions',
headers: 'Reusable header definitions for responses',
securitySchemes: 'Reusable security scheme definitions (e.g., API keys, OAuth2)',
links: 'Reusable descriptions of links between responses and operations',
callbacks: 'Reusable descriptions of callback operations',
// pathItems: 'Reusable path item definitions (rarely used directly here)' // Excluded as per comment above
};
// Use a Map for safer lookups against prototype pollution
const componentDescriptionsMap = new Map(Object.entries(componentTypeDescriptions));
/**
* Wraps an OpenAPIV3.ComponentsObject to make it renderable.
* Handles listing the available component types.
*/
export class RenderableComponents implements RenderableSpecObject {
constructor(private components: OpenAPIV3.ComponentsObject | undefined) {}
/**
* Renders a list of available component types found in the spec.
* Corresponds to the `openapi://components` URI.
*/
renderList(context: RenderContext): RenderResultItem[] {
if (!this.components || Object.keys(this.components).length === 0) {
return createErrorResult('components', 'No components found in the specification.');
}
const availableTypes = Object.keys(this.components).filter((key): key is ComponentType =>
VALID_COMPONENT_TYPES.includes(key as ComponentType)
);
if (availableTypes.length === 0) {
return createErrorResult('components', 'No valid component types found.');
}
let listText = 'Available Component Types:\n\n';
availableTypes.sort().forEach(type => {
const description = componentDescriptionsMap.get(type) ?? 'Unknown component type'; // Removed unnecessary 'as ComponentType'
listText += `- ${type}: ${description}\n`;
});
// Use the new hint generator structure, providing the first type as an example
const firstTypeExample = availableTypes.length > 0 ? availableTypes[0] : undefined;
listText += generateListHint(context, {
itemType: 'componentType',
firstItemExample: firstTypeExample,
});
return [
{
uriSuffix: 'components',
data: listText,
renderAsList: true,
},
];
}
/**
* Detail view for the main 'components' object isn't meaningful.
*/
renderDetail(context: RenderContext): RenderResultItem[] {
return this.renderList(context);
}
/**
* Gets the map object for a specific component type.
* @param type - The component type (e.g., 'schemas').
* @returns The map (e.g., ComponentsObject['schemas']) or undefined.
*/
getComponentMap(type: ComponentType):
| Record<
string,
| OpenAPIV3.SchemaObject
| OpenAPIV3.ResponseObject
| OpenAPIV3.ParameterObject
| OpenAPIV3.ExampleObject
| OpenAPIV3.RequestBodyObject
| OpenAPIV3.HeaderObject
| OpenAPIV3.SecuritySchemeObject
| OpenAPIV3.LinkObject
| OpenAPIV3.CallbackObject
| OpenAPIV3.ReferenceObject // Include ReferenceObject
>
| undefined {
// Use Map for safe access
if (!this.components) {
return undefined;
}
const componentsMap = new Map(Object.entries(this.components));
// Cast needed as Map.get returns the value type or undefined
return componentsMap.get(type) as ReturnType<RenderableComponents['getComponentMap']>;
}
}
// =====================================================================
/**
* Wraps a map of components of a specific type (e.g., all schemas).
* Handles listing component names and rendering component details.
*/
export class RenderableComponentMap implements RenderableSpecObject {
constructor(
private componentMap: ReturnType<RenderableComponents['getComponentMap']>,
private componentType: ComponentType, // e.g., 'schemas'
private mapUriSuffix: string // e.g., 'components/schemas'
) {}
/**
* Renders a list of component names for the specific type.
* Corresponds to the `openapi://components/{type}` URI.
*/
renderList(context: RenderContext): RenderResultItem[] {
if (!this.componentMap || Object.keys(this.componentMap).length === 0) {
return createErrorResult(
this.mapUriSuffix,
`No components of type "${this.componentType}" found.`
);
}
const names = Object.keys(this.componentMap).sort();
let listText = `Available ${this.componentType}:\n\n`;
names.forEach(name => {
listText += `- ${name}\n`;
});
// Use the new hint generator structure, providing parent type and first name as example
const firstNameExample = names.length > 0 ? names[0] : undefined;
listText += generateListHint(context, {
itemType: 'componentName',
parentComponentType: this.componentType,
firstItemExample: firstNameExample,
});
return [
{
uriSuffix: this.mapUriSuffix,
data: listText,
renderAsList: true,
},
];
}
/**
* Renders the detail view for one or more specific named components
* Renders the detail view. For a component map, this usually means listing
* the component names, similar to renderList. The handler should call
* `renderComponentDetail` for specific component details.
*/
renderDetail(context: RenderContext): RenderResultItem[] {
// Delegate to renderList as the primary view for a component map itself.
return this.renderList(context);
}
/**
* Renders the detail view for one or more specific named components
* within this map.
* Corresponds to the `openapi://components/{type}/{name*}` URI.
* This is called by the handler after identifying the name(s).
*
* @param _context - The rendering context (might be needed later).
* @param names - Array of component names.
* @returns An array of RenderResultItem representing the component details.
*/
renderComponentDetail(_context: RenderContext, names: string[]): RenderResultItem[] {
if (!this.componentMap) {
// Create error results for all requested names if map is missing
return names.map(name => ({
uriSuffix: `${this.mapUriSuffix}/${name}`,
data: null,
isError: true,
errorText: `Component map for type "${this.componentType}" not found.`,
renderAsList: true,
}));
}
const results: RenderResultItem[] = [];
for (const name of names) {
const component = this.getComponent(name);
const componentUriSuffix = `${this.mapUriSuffix}/${name}`;
if (!component) {
results.push({
uriSuffix: componentUriSuffix,
data: null,
isError: true,
errorText: `Component "${name}" of type "${this.componentType}" not found.`,
renderAsList: true,
});
} else {
// Return the raw component object; handler will format it
results.push({
uriSuffix: componentUriSuffix,
data: component,
});
}
}
return results;
}
/**
* Gets a specific component object by name.
* @param name - The name of the component.
* @returns The component object (or ReferenceObject) or undefined.
*/
getComponent(
name: string
):
| OpenAPIV3.SchemaObject
| OpenAPIV3.ResponseObject
| OpenAPIV3.ParameterObject
| OpenAPIV3.ExampleObject
| OpenAPIV3.RequestBodyObject
| OpenAPIV3.HeaderObject
| OpenAPIV3.SecuritySchemeObject
| OpenAPIV3.LinkObject
| OpenAPIV3.CallbackObject
| OpenAPIV3.ReferenceObject
| undefined {
// Use Map for safe access
if (!this.componentMap) {
return undefined;
}
const detailsMap = new Map(Object.entries(this.componentMap));
// No cast needed, Map.get returns the correct type (ValueType | undefined)
return detailsMap.get(name);
}
}
```
--------------------------------------------------------------------------------
/test/__tests__/unit/services/spec-loader.test.ts:
--------------------------------------------------------------------------------
```typescript
import { SpecLoaderService } from '../../../../src/services/spec-loader.js';
import { ReferenceTransformService } from '../../../../src/services/reference-transform.js';
import { OpenAPIV3 } from 'openapi-types';
// Define mock implementations first
const mockConvertUrlImplementation = jest.fn();
const mockConvertFileImplementation = jest.fn();
// Mock the module, referencing the defined implementations
// IMPORTANT: The factory function for jest.mock runs BEFORE top-level variable assignments in the module scope.
// We need to access the mocks indirectly.
interface Swagger2OpenapiResult {
openapi: OpenAPIV3.Document;
options: unknown; // Use unknown for options as we don't have precise types here
}
jest.mock('swagger2openapi', () => {
// Return an object where the properties are functions that call our mocks
return {
convertUrl: (url: string, options: unknown): Promise<Swagger2OpenapiResult> =>
mockConvertUrlImplementation(url, options) as Promise<Swagger2OpenapiResult>, // Cast return type
convertFile: (filename: string, options: unknown): Promise<Swagger2OpenapiResult> =>
mockConvertFileImplementation(filename, options) as Promise<Swagger2OpenapiResult>, // Cast return type
};
});
describe('SpecLoaderService', () => {
const mockV3Spec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: {
title: 'Test V3 API',
version: '1.0.0',
},
paths: {},
};
// Simulate the structure returned by swagger2openapi
const mockS2OResult = {
openapi: mockV3Spec,
options: {}, // Add other properties if needed by tests
};
let referenceTransform: ReferenceTransformService;
beforeEach(() => {
// Reset the mock implementations
mockConvertUrlImplementation.mockReset();
mockConvertFileImplementation.mockReset();
referenceTransform = new ReferenceTransformService();
// Mock the transformDocument method for simplicity in these tests
jest.spyOn(referenceTransform, 'transformDocument').mockImplementation(spec => spec);
});
describe('loadSpec', () => {
it('loads local v3 spec using convertFile', async () => {
mockConvertFileImplementation.mockResolvedValue(mockS2OResult);
const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform);
const spec = await loader.loadSpec();
expect(mockConvertFileImplementation).toHaveBeenCalledWith(
'/path/to/spec.json',
expect.any(Object)
);
expect(mockConvertUrlImplementation).not.toHaveBeenCalled();
expect(spec).toEqual(mockV3Spec);
});
it('loads remote v3 spec using convertUrl', async () => {
mockConvertUrlImplementation.mockResolvedValue(mockS2OResult);
const loader = new SpecLoaderService('http://example.com/spec.json', referenceTransform);
const spec = await loader.loadSpec();
expect(mockConvertUrlImplementation).toHaveBeenCalledWith(
'http://example.com/spec.json',
expect.any(Object)
);
expect(mockConvertFileImplementation).not.toHaveBeenCalled();
expect(spec).toEqual(mockV3Spec);
});
it('loads and converts local v2 spec using convertFile', async () => {
// Assume convertFile handles v2 internally and returns v3
mockConvertFileImplementation.mockResolvedValue(mockS2OResult);
const loader = new SpecLoaderService('/path/to/v2spec.json', referenceTransform);
const spec = await loader.loadSpec();
expect(mockConvertFileImplementation).toHaveBeenCalledWith(
'/path/to/v2spec.json',
expect.any(Object)
);
expect(mockConvertUrlImplementation).not.toHaveBeenCalled();
expect(spec).toEqual(mockV3Spec); // Should be the converted v3 spec
});
it('loads and converts remote v2 spec using convertUrl', async () => {
// Assume convertUrl handles v2 internally and returns v3
mockConvertUrlImplementation.mockResolvedValue(mockS2OResult);
const loader = new SpecLoaderService('https://example.com/v2spec.yaml', referenceTransform);
const spec = await loader.loadSpec();
expect(mockConvertUrlImplementation).toHaveBeenCalledWith(
'https://example.com/v2spec.yaml',
expect.any(Object)
);
expect(mockConvertFileImplementation).not.toHaveBeenCalled();
expect(spec).toEqual(mockV3Spec); // Should be the converted v3 spec
});
it('throws error if convertFile fails', async () => {
const loadError = new Error('File not found');
mockConvertFileImplementation.mockRejectedValue(loadError);
const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform);
await expect(loader.loadSpec()).rejects.toThrow(
'Failed to load/convert OpenAPI spec from /path/to/spec.json: File not found'
);
});
it('throws error if convertUrl fails', async () => {
const loadError = new Error('Network error');
mockConvertUrlImplementation.mockRejectedValue(loadError);
const loader = new SpecLoaderService('http://example.com/spec.json', referenceTransform);
await expect(loader.loadSpec()).rejects.toThrow(
'Failed to load/convert OpenAPI spec from http://example.com/spec.json: Network error'
);
});
it('throws error if result object is invalid', async () => {
mockConvertFileImplementation.mockResolvedValue({ options: {} }); // Missing openapi property
const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform);
await expect(loader.loadSpec()).rejects.toThrow(
'Failed to load/convert OpenAPI spec from /path/to/spec.json: Conversion or parsing failed to produce an OpenAPI document.'
);
});
});
describe('getSpec', () => {
it('returns loaded spec after loadSpec called', async () => {
mockConvertFileImplementation.mockResolvedValue(mockS2OResult);
const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform);
await loader.loadSpec(); // Load first
const spec = await loader.getSpec();
expect(spec).toEqual(mockV3Spec);
// Ensure loadSpec was only called once implicitly by the first await
expect(mockConvertFileImplementation).toHaveBeenCalledTimes(1);
});
it('loads spec via convertFile if not already loaded', async () => {
mockConvertFileImplementation.mockResolvedValue(mockS2OResult);
const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform);
const spec = await loader.getSpec(); // Should trigger loadSpec
expect(mockConvertFileImplementation).toHaveBeenCalledWith(
'/path/to/spec.json',
expect.any(Object)
);
expect(spec).toEqual(mockV3Spec);
});
it('loads spec via convertUrl if not already loaded', async () => {
mockConvertUrlImplementation.mockResolvedValue(mockS2OResult);
const loader = new SpecLoaderService('http://example.com/spec.json', referenceTransform);
const spec = await loader.getSpec(); // Should trigger loadSpec
expect(mockConvertUrlImplementation).toHaveBeenCalledWith(
'http://example.com/spec.json',
expect.any(Object)
);
expect(spec).toEqual(mockV3Spec);
});
});
describe('getTransformedSpec', () => {
// Mock the transformer to return a distinctly modified object
const mockTransformedSpec = {
...mockV3Spec,
info: { ...mockV3Spec.info, title: 'Transformed API' },
};
beforeEach(() => {
jest
.spyOn(referenceTransform, 'transformDocument')
.mockImplementation(() => mockTransformedSpec);
});
it('returns transformed spec after loading', async () => {
mockConvertFileImplementation.mockResolvedValue(mockS2OResult);
const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform);
const spec = await loader.getTransformedSpec({ resourceType: 'endpoint', format: 'openapi' }); // Should load then transform
expect(mockConvertFileImplementation).toHaveBeenCalledTimes(1); // Ensure loading happened
const transformSpy = jest.spyOn(referenceTransform, 'transformDocument');
expect(transformSpy).toHaveBeenCalledWith(
mockV3Spec,
expect.objectContaining({ resourceType: 'endpoint', format: 'openapi' })
);
expect(spec).toEqual(mockTransformedSpec);
});
it('loads spec if not loaded before transforming', async () => {
mockConvertFileImplementation.mockResolvedValue(mockS2OResult);
const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform);
await loader.getTransformedSpec({ resourceType: 'endpoint', format: 'openapi' }); // Trigger load
expect(mockConvertFileImplementation).toHaveBeenCalledWith(
'/path/to/spec.json',
expect.any(Object)
);
});
});
});
```
--------------------------------------------------------------------------------
/test/__tests__/unit/services/reference-transform.test.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIV3 } from 'openapi-types';
import {
OpenAPITransformer,
ReferenceTransformService,
TransformContext,
} from '../../../../src/services/reference-transform';
describe('ReferenceTransformService', () => {
let service: ReferenceTransformService;
let transformer: OpenAPITransformer;
beforeEach(() => {
service = new ReferenceTransformService();
transformer = new OpenAPITransformer();
service.registerTransformer('openapi', transformer);
});
it('throws error for unknown format', () => {
const context: TransformContext = {
resourceType: 'endpoint',
format: 'unknown' as 'openapi' | 'asyncapi' | 'graphql',
};
expect(() => service.transformDocument({}, context)).toThrow(
'No transformer registered for format: unknown'
);
});
it('transforms document using registered transformer', () => {
const context: TransformContext = {
resourceType: 'endpoint',
format: 'openapi',
path: '/tasks',
method: 'get',
};
const doc: OpenAPIV3.Document = {
openapi: '3.0.0',
info: {
title: 'Test API',
version: '1.0.0',
},
paths: {
'/tasks': {
get: {
responses: {
'200': {
description: 'Success',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Task',
},
},
},
},
},
},
},
},
};
const result = service.transformDocument(doc, context);
const operation = result.paths?.['/tasks']?.get;
const response = operation?.responses?.['200'];
expect(response).toBeDefined();
expect('content' in response!).toBeTruthy();
const responseObj = response! as OpenAPIV3.ResponseObject;
expect(responseObj.content?.['application/json']?.schema).toBeDefined();
// Expect the new format
expect(responseObj.content!['application/json'].schema).toEqual({
$ref: 'openapi://components/schemas/Task',
});
});
});
describe('OpenAPITransformer', () => {
let transformer: OpenAPITransformer;
beforeEach(() => {
transformer = new OpenAPITransformer();
});
it('transforms schema references', () => {
const context: TransformContext = {
resourceType: 'endpoint',
format: 'openapi',
};
const doc: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
schemas: {
Task: {
$ref: '#/components/schemas/TaskId',
},
},
},
};
const result = transformer.transformRefs(doc, context);
// Expect the new format
expect(result.components?.schemas?.Task).toEqual({
$ref: 'openapi://components/schemas/TaskId',
});
});
it('handles nested references', () => {
const context: TransformContext = {
resourceType: 'endpoint',
format: 'openapi',
};
const doc: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/tasks': {
post: {
requestBody: {
required: true,
description: 'Task creation',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Task',
},
},
},
},
responses: {
'201': {
description: 'Created',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Task',
},
},
},
},
},
},
},
},
};
const result = transformer.transformRefs(doc, context);
const taskPath = result.paths?.['/tasks'];
expect(taskPath?.post).toBeDefined();
const operation = taskPath!.post!;
expect(operation.requestBody).toBeDefined();
expect('content' in operation.requestBody!).toBeTruthy();
const requestBody = operation.requestBody! as OpenAPIV3.RequestBodyObject;
expect(requestBody.content?.['application/json']?.schema).toBeDefined();
// Expect the new format
expect(requestBody.content['application/json'].schema).toEqual({
$ref: 'openapi://components/schemas/Task',
});
// Also check the response reference in the same test
const response = operation.responses?.['201'];
expect(response).toBeDefined();
expect('content' in response).toBeTruthy();
const responseObj = response as OpenAPIV3.ResponseObject;
expect(responseObj.content?.['application/json']?.schema).toBeDefined();
// Expect the new format
expect(responseObj.content!['application/json'].schema).toEqual({
$ref: 'openapi://components/schemas/Task',
});
});
it('keeps external references unchanged', () => {
const context: TransformContext = {
resourceType: 'endpoint',
format: 'openapi',
};
const doc: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
schemas: {
Task: {
$ref: 'https://example.com/schemas/Task',
},
},
},
};
const result = transformer.transformRefs(doc, context);
const task = result.components?.schemas?.Task as OpenAPIV3.ReferenceObject;
expect(task).toEqual({
$ref: 'https://example.com/schemas/Task',
});
});
// This test is now invalid as non-schema internal refs *should* be transformed
// it('keeps non-schema internal references unchanged', () => { ... });
it('transforms parameter references', () => {
const context: TransformContext = {
resourceType: 'endpoint',
format: 'openapi',
};
const doc: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
parameters: {
UserIdParam: {
$ref: '#/components/parameters/UserId', // Reference another parameter
},
},
},
};
const result = transformer.transformRefs(doc, context);
expect(result.components?.parameters?.UserIdParam).toEqual({
$ref: 'openapi://components/parameters/UserId', // Expect transformation
});
});
it('transforms response references', () => {
const context: TransformContext = {
resourceType: 'endpoint',
format: 'openapi',
};
const doc: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
responses: {
GenericError: {
$ref: '#/components/responses/ErrorModel', // Reference another response
},
},
},
};
const result = transformer.transformRefs(doc, context);
expect(result.components?.responses?.GenericError).toEqual({
$ref: 'openapi://components/responses/ErrorModel', // Expect transformation
});
});
// Add tests for other component types if needed (examples, requestBodies, etc.)
it('handles arrays properly', () => {
const context: TransformContext = {
resourceType: 'endpoint',
format: 'openapi',
};
const doc: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
schemas: {
TaskList: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
$ref: '#/components/schemas/Task',
},
},
},
},
},
},
};
const result = transformer.transformRefs(doc, context);
const schema = result.components?.schemas?.TaskList;
expect(schema).toBeDefined();
expect('properties' in schema!).toBeTruthy();
const schemaObject = schema! as OpenAPIV3.SchemaObject;
expect(schemaObject.properties?.items).toBeDefined();
const arraySchema = schemaObject.properties!.items as OpenAPIV3.ArraySchemaObject;
// Expect the new format
expect(arraySchema.items).toEqual({
$ref: 'openapi://components/schemas/Task',
});
});
it('preserves non-reference values', () => {
const context: TransformContext = {
resourceType: 'endpoint',
format: 'openapi',
};
const doc: OpenAPIV3.Document = {
openapi: '3.0.0',
info: {
title: 'Test API',
version: '1.0.0',
},
paths: {},
components: {
schemas: {
Test: {
type: 'object',
properties: {
name: { type: 'string' },
},
},
},
},
};
const result = transformer.transformRefs(doc, context);
expect(result).toEqual(doc);
});
});
```
--------------------------------------------------------------------------------
/src/handlers/handler-utils.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIV3 } from 'openapi-types';
import { RenderContext, RenderResultItem } from '../rendering/types.js'; // Already has .js
// Remove McpError/ErrorCode import - use standard Error
// Define the structure expected for each item in the contents array
export type FormattedResultItem = {
uri: string;
mimeType?: string;
text: string;
isError?: boolean;
};
/**
* Formats RenderResultItem array into an array compatible with the 'contents'
* property of ReadResourceResultSchema (specifically TextResourceContents).
*/
export function formatResults(
context: RenderContext,
items: RenderResultItem[]
): FormattedResultItem[] {
// Add type check for formatter existence in context
if (!context.formatter) {
throw new Error('Formatter is missing in RenderContext for formatResults');
}
return items.map(item => {
const uri = `${context.baseUri}${item.uriSuffix}`;
let text: string;
let mimeType: string;
if (item.isError) {
text = item.errorText || 'An unknown error occurred.';
mimeType = 'text/plain';
} else if (item.renderAsList) {
text = typeof item.data === 'string' ? item.data : 'Invalid list data';
mimeType = 'text/plain';
} else {
// Detail view: format using the provided formatter
try {
text = context.formatter.format(item.data);
mimeType = context.formatter.getMimeType();
} catch (formatError: unknown) {
text = `Error formatting data for ${uri}: ${
formatError instanceof Error ? formatError.message : String(formatError)
}`;
mimeType = 'text/plain';
// Ensure isError is true if formatting fails
item.isError = true;
item.errorText = text; // Store the formatting error message
}
}
// Construct the final object, prioritizing item.isError
const finalItem: FormattedResultItem = {
uri: uri,
mimeType: mimeType,
text: item.isError ? item.errorText || 'An unknown error occurred.' : text,
isError: item.isError ?? false, // Default to false if not explicitly set
};
// Ensure mimeType is text/plain for errors
if (finalItem.isError) {
finalItem.mimeType = 'text/plain';
}
return finalItem;
});
}
/**
* Type guard to check if an object is an OpenAPIV3.Document.
*/
export function isOpenAPIV3(spec: unknown): spec is OpenAPIV3.Document {
return (
typeof spec === 'object' &&
spec !== null &&
'openapi' in spec &&
typeof (spec as { openapi: unknown }).openapi === 'string' &&
(spec as { openapi: string }).openapi.startsWith('3.')
);
}
/**
* Safely retrieves a PathItemObject from the specification using a Map.
* Throws an McpError if the path is not found.
*
* @param spec The OpenAPIV3 Document.
* @param path The decoded path string (e.g., '/users/{id}').
* @returns The validated PathItemObject.
* @throws {McpError} If the path is not found in spec.paths.
*/
export function getValidatedPathItem(
spec: OpenAPIV3.Document,
path: string
): OpenAPIV3.PathItemObject {
if (!spec.paths) {
// Use standard Error
throw new Error('Specification does not contain any paths.');
}
const pathsMap = new Map(Object.entries(spec.paths));
const pathItem = pathsMap.get(path);
if (!pathItem) {
const errorMessage = `Path "${path}" not found in the specification.`;
// Use standard Error
throw new Error(errorMessage);
}
// We assume the spec structure is valid if the key exists
return pathItem as OpenAPIV3.PathItemObject;
}
/**
* Validates requested HTTP methods against a PathItemObject using a Map.
* Returns the list of valid requested methods.
* Throws an McpError if none of the requested methods are valid for the path item.
*
* @param pathItem The PathItemObject to check against.
* @param requestedMethods An array of lowercase HTTP methods requested by the user.
* @param pathForError The path string, used for creating informative error messages.
* @returns An array of the requested methods that are valid for this path item.
* @throws {McpError} If none of the requested methods are valid.
*/
export function getValidatedOperations(
pathItem: OpenAPIV3.PathItemObject,
requestedMethods: string[],
pathForError: string
): string[] {
const operationsMap = new Map<string, OpenAPIV3.OperationObject>();
Object.entries(pathItem).forEach(([method, operation]) => {
// Check if the key is a standard HTTP method before adding
if (
['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'].includes(
method.toLowerCase()
)
) {
operationsMap.set(method.toLowerCase(), operation as OpenAPIV3.OperationObject);
}
});
// Validate using lowercase versions, but preserve original case for return
const requestedMethodsLower = requestedMethods.map(m => m.toLowerCase());
const validLowerMethods = requestedMethodsLower.filter(m => operationsMap.has(m));
if (validLowerMethods.length === 0) {
const availableMethods = Array.from(operationsMap.keys()).join(', ');
// Show original case in error message for clarity
const errorMessage = `None of the requested methods (${requestedMethods.join(', ')}) are valid for path "${pathForError}". Available methods: ${availableMethods}`;
// Use standard Error
throw new Error(errorMessage);
}
// Return the methods from the *original* requestedMethods array
// that correspond to the valid lowercase methods found.
return requestedMethods.filter(m => validLowerMethods.includes(m.toLowerCase()));
}
/**
* Safely retrieves the component map for a specific type (e.g., schemas, responses)
* from the specification using a Map.
* Throws an McpError if spec.components or the specific type map is not found.
*
* @param spec The OpenAPIV3 Document.
* @param type The ComponentType string (e.g., 'schemas', 'responses').
* @returns The validated component map object (e.g., spec.components.schemas).
* @throws {McpError} If spec.components or the type map is not found.
*/
export function getValidatedComponentMap(
spec: OpenAPIV3.Document,
type: string // Keep as string for validation flexibility
): NonNullable<OpenAPIV3.ComponentsObject[keyof OpenAPIV3.ComponentsObject]> {
if (!spec.components) {
// Use standard Error
throw new Error('Specification does not contain a components section.');
}
// Validate the requested type against the actual keys in spec.components
const componentsMap = new Map(Object.entries(spec.components));
// Add type assertion for clarity, although the check below handles undefined
const componentMapObj = componentsMap.get(type) as
| OpenAPIV3.ComponentsObject[keyof OpenAPIV3.ComponentsObject]
| undefined;
if (!componentMapObj) {
const availableTypes = Array.from(componentsMap.keys()).join(', ');
const errorMessage = `Component type "${type}" not found in the specification. Available types: ${availableTypes}`;
// Use standard Error
throw new Error(errorMessage);
}
// We assume the spec structure is valid if the key exists
return componentMapObj as NonNullable<
OpenAPIV3.ComponentsObject[keyof OpenAPIV3.ComponentsObject]
>;
}
/**
* Validates requested component names against a specific component map (e.g., schemas).
* Returns an array of objects containing the valid name and its corresponding detail object.
* Throws an McpError if none of the requested names are valid for the component map.
*
* @param componentMap The specific component map object (e.g., spec.components.schemas).
* @param requestedNames An array of component names requested by the user.
* @param componentTypeForError The component type string, used for creating informative error messages.
* @param detailsMap A Map created from the specific component map object (e.g., new Map(Object.entries(spec.components.schemas))).
* @param requestedNames An array of component names requested by the user.
* @param componentTypeForError The component type string, used for creating informative error messages.
* @returns An array of { name: string, detail: V } for valid requested names, where V is the value type of the Map.
* @throws {McpError} If none of the requested names are valid.
*/
// Modify to accept a Map directly
export function getValidatedComponentDetails<V extends OpenAPIV3.ReferenceObject | object>(
detailsMap: Map<string, V>, // Accept Map<string, V>
requestedNames: string[],
componentTypeForError: string
): { name: string; detail: V }[] {
// No longer need to create the map inside the function
const validDetails = requestedNames
.map(name => {
const detail = detailsMap.get(name); // detail will be V | undefined
return detail ? { name, detail } : null;
})
// Type predicate ensures we filter out nulls and have the correct type
.filter((item): item is { name: string; detail: V } => item !== null);
if (validDetails.length === 0) {
// Sort available names for deterministic error messages
const availableNames = Array.from(detailsMap.keys()).sort().join(', ');
const errorMessage = `None of the requested names (${requestedNames.join(', ')}) are valid for component type "${componentTypeForError}". Available names: ${availableNames}`;
// Use standard Error
throw new Error(errorMessage);
}
return validDetails;
}
```
--------------------------------------------------------------------------------
/test/__tests__/unit/handlers/component-detail-handler.test.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIV3 } from 'openapi-types';
import { RequestId } from '@modelcontextprotocol/sdk/types.js';
import { ComponentDetailHandler } from '../../../../src/handlers/component-detail-handler';
import { SpecLoaderService } from '../../../../src/types';
import { IFormatter, JsonFormatter } from '../../../../src/services/formatters';
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js';
import { suppressExpectedConsoleError } from '../../../utils/console-helpers';
// Mocks
const mockGetTransformedSpec = jest.fn();
const mockSpecLoader: SpecLoaderService = {
getSpec: jest.fn(),
getTransformedSpec: mockGetTransformedSpec,
};
const mockFormatter: IFormatter = new JsonFormatter();
// Sample Data
const userSchema: OpenAPIV3.SchemaObject = {
type: 'object',
properties: { name: { type: 'string' } },
};
const errorSchema: OpenAPIV3.SchemaObject = {
type: 'object',
properties: { message: { type: 'string' } },
};
const limitParam: OpenAPIV3.ParameterObject = {
name: 'limit',
in: 'query',
schema: { type: 'integer' },
};
const sampleSpec: OpenAPIV3.Document = {
openapi: '3.0.3',
info: { title: 'Test API', version: '1.0.0' },
paths: {},
components: {
schemas: {
User: userSchema,
Error: errorSchema,
},
parameters: {
limitParam: limitParam,
},
// No securitySchemes defined
},
};
describe('ComponentDetailHandler', () => {
let handler: ComponentDetailHandler;
beforeEach(() => {
handler = new ComponentDetailHandler(mockSpecLoader, mockFormatter);
mockGetTransformedSpec.mockReset();
mockGetTransformedSpec.mockResolvedValue(sampleSpec); // Default mock
});
it('should return the correct template', () => {
const template = handler.getTemplate();
expect(template).toBeInstanceOf(ResourceTemplate);
expect(template.uriTemplate.toString()).toBe('openapi://components/{type}/{name*}');
});
describe('handleRequest', () => {
const mockExtra = {
signal: new AbortController().signal,
sendNotification: jest.fn(),
sendRequest: jest.fn(),
requestId: 'test-request-id' as RequestId,
};
it('should return detail for a single valid component (schema)', async () => {
const variables: Variables = { type: 'schemas', name: 'User' }; // Use 'name' key
const uri = new URL('openapi://components/schemas/User');
const result = await handler.handleRequest(uri, variables, mockExtra);
expect(mockGetTransformedSpec).toHaveBeenCalledWith({
resourceType: 'schema',
format: 'openapi',
});
expect(result.contents).toHaveLength(1);
expect(result.contents[0]).toEqual({
uri: 'openapi://components/schemas/User',
mimeType: 'application/json',
text: JSON.stringify(userSchema, null, 2),
isError: false,
});
});
it('should return detail for a single valid component (parameter)', async () => {
const variables: Variables = { type: 'parameters', name: 'limitParam' };
const uri = new URL('openapi://components/parameters/limitParam');
const result = await handler.handleRequest(uri, variables, mockExtra);
expect(result.contents).toHaveLength(1);
expect(result.contents[0]).toEqual({
uri: 'openapi://components/parameters/limitParam',
mimeType: 'application/json',
text: JSON.stringify(limitParam, null, 2),
isError: false,
});
});
it('should return details for multiple valid components (array input)', async () => {
const variables: Variables = { type: 'schemas', name: ['User', 'Error'] }; // Use 'name' key with array
const uri = new URL('openapi://components/schemas/User,Error'); // URI might not reflect array input
const result = await handler.handleRequest(uri, variables, mockExtra);
expect(result.contents).toHaveLength(2);
expect(result.contents).toContainEqual({
uri: 'openapi://components/schemas/User',
mimeType: 'application/json',
text: JSON.stringify(userSchema, null, 2),
isError: false,
});
expect(result.contents).toContainEqual({
uri: 'openapi://components/schemas/Error',
mimeType: 'application/json',
text: JSON.stringify(errorSchema, null, 2),
isError: false,
});
});
it('should return error for invalid component type', async () => {
const variables: Variables = { type: 'invalidType', name: 'SomeName' };
const uri = new URL('openapi://components/invalidType/SomeName');
const expectedLogMessage = /Invalid component type: invalidType/;
const result = await suppressExpectedConsoleError(expectedLogMessage, () =>
handler.handleRequest(uri, variables, mockExtra)
);
expect(result.contents).toHaveLength(1);
expect(result.contents[0]).toEqual({
uri: 'openapi://components/invalidType/SomeName',
mimeType: 'text/plain',
text: 'Invalid component type: invalidType',
isError: true,
});
expect(mockGetTransformedSpec).not.toHaveBeenCalled();
});
it('should return error for non-existent component type in spec', async () => {
const variables: Variables = { type: 'securitySchemes', name: 'apiKey' };
const uri = new URL('openapi://components/securitySchemes/apiKey');
const expectedLogMessage = /Component type "securitySchemes" not found/;
const result = await suppressExpectedConsoleError(expectedLogMessage, () =>
handler.handleRequest(uri, variables, mockExtra)
);
expect(result.contents).toHaveLength(1);
// Expect the specific error message from getValidatedComponentMap
expect(result.contents[0]).toEqual({
uri: 'openapi://components/securitySchemes/apiKey',
mimeType: 'text/plain',
text: 'Component type "securitySchemes" not found in the specification. Available types: schemas, parameters',
isError: true,
});
});
it('should return error for non-existent component name', async () => {
const variables: Variables = { type: 'schemas', name: 'NonExistent' };
const uri = new URL('openapi://components/schemas/NonExistent');
const expectedLogMessage = /None of the requested names \(NonExistent\) are valid/;
const result = await suppressExpectedConsoleError(expectedLogMessage, () =>
handler.handleRequest(uri, variables, mockExtra)
);
expect(result.contents).toHaveLength(1);
// Expect the specific error message from getValidatedComponentDetails
expect(result.contents[0]).toEqual({
uri: 'openapi://components/schemas/NonExistent',
mimeType: 'text/plain',
// Expect sorted names: Error, User
text: 'None of the requested names (NonExistent) are valid for component type "schemas". Available names: Error, User',
isError: true,
});
});
// Remove test for mix of valid/invalid names, as getValidatedComponentDetails throws now
// it('should handle mix of valid and invalid component names', async () => { ... });
it('should handle empty name array', async () => {
const variables: Variables = { type: 'schemas', name: [] };
const uri = new URL('openapi://components/schemas/');
const expectedLogMessage = /No valid component name specified/;
const result = await suppressExpectedConsoleError(expectedLogMessage, () =>
handler.handleRequest(uri, variables, mockExtra)
);
expect(result.contents).toHaveLength(1);
expect(result.contents[0]).toEqual({
uri: 'openapi://components/schemas/',
mimeType: 'text/plain',
text: 'No valid component name specified.',
isError: true,
});
});
it('should handle spec loading errors', async () => {
const error = new Error('Spec load failed');
mockGetTransformedSpec.mockRejectedValue(error);
const variables: Variables = { type: 'schemas', name: 'User' };
const uri = new URL('openapi://components/schemas/User');
const expectedLogMessage = /Spec load failed/;
const result = await suppressExpectedConsoleError(expectedLogMessage, () =>
handler.handleRequest(uri, variables, mockExtra)
);
expect(result.contents).toHaveLength(1);
expect(result.contents[0]).toEqual({
uri: 'openapi://components/schemas/User',
mimeType: 'text/plain',
text: 'Spec load failed',
isError: true,
});
});
it('should handle non-OpenAPI v3 spec', async () => {
const invalidSpec = { swagger: '2.0', info: {} };
mockGetTransformedSpec.mockResolvedValue(invalidSpec as unknown as OpenAPIV3.Document);
const variables: Variables = { type: 'schemas', name: 'User' };
const uri = new URL('openapi://components/schemas/User');
const expectedLogMessage = /Only OpenAPI v3 specifications are supported/;
const result = await suppressExpectedConsoleError(expectedLogMessage, () =>
handler.handleRequest(uri, variables, mockExtra)
);
expect(result.contents).toHaveLength(1);
expect(result.contents[0]).toEqual({
uri: 'openapi://components/schemas/User',
mimeType: 'text/plain',
text: 'Only OpenAPI v3 specifications are supported',
isError: true,
});
});
});
});
```
--------------------------------------------------------------------------------
/memory-bank/progress.md:
--------------------------------------------------------------------------------
```markdown
# Project Progress
## Completed Features
### Core Refactoring & New Resource Structure (✓)
1. **Unified URI Structure:** Implemented a consistent URI structure based on OpenAPI spec hierarchy:
- `openapi://{field}`: Access top-level fields (info, servers, tags) or list paths/component types.
- `openapi://paths/{path}`: List methods for a specific path.
- `openapi://paths/{path}/{method*}`: Get details for one or more operations.
- `openapi://components/{type}`: List names for a specific component type.
- `openapi://components/{type}/{name*}`: Get details for one or more components.
2. **OOP Rendering Layer:** Introduced `Renderable*` classes (`RenderableDocument`, `RenderablePaths`, `RenderablePathItem`, `RenderableComponents`, `RenderableComponentMap`) to encapsulate rendering logic.
- Uses `RenderContext` and intermediate `RenderResultItem` structure.
- Supports token-efficient text lists and formatted detail views (JSON/YAML).
3. **Refactored Handlers:** Created new, focused handlers for each URI pattern:
- `TopLevelFieldHandler`
- `PathItemHandler`
- `OperationHandler`
- `ComponentMapHandler`
- `ComponentDetailHandler`
- Uses shared utilities (`handler-utils.ts`).
4. **Multi-Value Support:** Correctly handles `*` variables (`method*`, `name*`) passed as arrays by the SDK.
5. **Testing:**
- Added unit tests for all new `Renderable*` classes.
- Added unit tests for all new handler classes.
- Added E2E tests covering the new URI structure and functionality using `complex-endpoint.json`.
6. **Archived Old Code:** Moved previous handler/test implementations to `local-docs/old-implementation/`.
### Previous Features (Now Integrated/Superseded)
- Schema Listing (Superseded by `openapi://components/schemas`)
- Schema Details (Superseded by `openapi://components/schemas/{name*}`)
- Endpoint Details (Superseded by `openapi://paths/{path}/{method*}`)
- Endpoint List (Superseded by `openapi://paths`)
### Remote Spec & Swagger v2.0 Support (✓)
1. **Remote Loading:** Added support for loading specifications via HTTP/HTTPS URLs using `swagger2openapi`.
2. **Swagger v2.0 Conversion:** Added support for automatically converting Swagger v2.0 specifications to OpenAPI v3.0 using `swagger2openapi`.
3. **Dependency Change:** Replaced `@apidevtools/swagger-parser` with `swagger2openapi` for loading and conversion.
4. **Configuration Update:** Confirmed and documented that configuration uses CLI arguments (`<path-or-url-to-spec>`) instead of environment variables.
5. **Testing:**
- Updated `SpecLoaderService` unit tests to mock `swagger2openapi` and cover new scenarios (local/remote, v2/v3).
- Created new E2E test file (`spec-loading.test.ts`) to verify loading from local v2 and remote v3 sources.
- Added v2.0 test fixture (`sample-v2-api.json`).
### Docker Support (✓)
1. **Dockerfile:** Added a multi-stage production `Dockerfile` at the root. Moved the devcontainer Dockerfile to `.devcontainer/`.
2. **Release Integration:** Added `@codedependant/semantic-release-docker` plugin to `.releaserc.json` to automate Docker image building and publishing to Docker Hub (`kadykov/mcp-openapi-schema-explorer`).
3. **CI Workflow:** Updated the `release` job in `.github/workflows/ci.yml` to set up Docker environment (QEMU, Buildx, Login) and use `cycjimmy/semantic-release-action@v4` with the Docker plugin included in `extra_plugins`. Removed redundant Node setup/install steps from the release job.
4. **Documentation:** Updated `README.md` with instructions and examples for running the server via Docker.
## Technical Features (✓)
### Codebase Organization (Updated)
1. File Structure
- `src/handlers/`: Contains individual handlers and `handler-utils.ts`.
- `src/rendering/`: Contains `Renderable*` classes, `types.ts`, `utils.ts`.
- `src/services/`: Updated `spec-loader.ts` to use `swagger2openapi`. Added `formatters.ts`.
- `src/`: `index.ts`, `config.ts`, `types.ts`.
- `test/`: Updated unit tests (`spec-loader.test.ts`, `formatters.test.ts`), added new E2E test (`spec-loading.test.ts`), added v2 fixture. Updated `format.test.ts`. Updated `mcp-test-helpers.ts`.
- `local-docs/old-implementation/`: Archived previous code.
2. Testing Structure
- Unit tests for rendering classes (`test/__tests__/unit/rendering/`).
- Unit tests for handlers (`test/__tests__/unit/handlers/`).
- Unit tests for services (`spec-loader.test.ts`, `reference-transform.test.ts`, `formatters.test.ts`).
- E2E tests (`refactored-resources.test.ts`, `spec-loading.test.ts`, `format.test.ts`). Added tests for `json-minified`.
- Fixtures (`test/fixtures/`, including v2 and v3).
- Test utils (`test/utils/`). Updated `StartServerOptions` type.
3. Type System
- OpenAPI v3 types.
- `RenderableSpecObject`, `RenderContext`, `RenderResultItem` interfaces.
- `FormattedResultItem` type for handler results.
- `OutputFormat` type updated.
- `IFormatter` interface.
4. Error Handling
- Consistent error handling via `createErrorResult` and `formatResults`.
- Errors formatted as `text/plain`.
### Reference Transformation (✓ - Updated)
- Centralized URI generation logic in `src/utils/uri-builder.ts`.
- `ReferenceTransformService` now correctly transforms all `#/components/...` refs to `openapi://components/{type}/{name}` using the URI builder.
- Path encoding corrected to remove leading slashes before encoding.
- Unit tests updated and passing for URI builder and transformer.
### Output Format Enhancement (✓)
- Added `json-minified` output format option (`--output-format json-minified`).
- Implemented `MinifiedJsonFormatter` in `src/services/formatters.ts`.
- Updated configuration (`src/config.ts`) to accept the new format.
- Added unit tests for the new formatter (`test/__tests__/unit/services/formatters.test.ts`).
- Added E2E tests (`test/__tests__/e2e/format.test.ts`) to verify the new format.
- Updated test helper types (`test/utils/mcp-test-helpers.ts`).
### Dynamic Server Name (✓)
- Modified `src/index.ts` to load the OpenAPI spec before server initialization.
- Extracted `info.title` from the loaded spec.
- Set the `McpServer` name dynamically using the format `Schema Explorer for {title}` with a fallback.
### Dependency Cleanup & Release Automation (✓)
1. **Dependency Correction:** Correctly categorized runtime (`swagger2openapi`) vs. development (`@types/*`) dependencies in `package.json`. Removed unused types.
2. **Automated Releases:** Implemented `semantic-release` with conventional commit analysis, changelog generation, npm publishing, and GitHub releases.
3. **Dynamic Versioning:** Server version is now dynamically injected via `src/version.ts`, which is generated during the release process by `semantic-release` using `scripts/generate-version.js`. A default version file is tracked in Git for local builds.
4. **CI Workflow:** Updated `.github/workflows/ci.yml` to use Node 22, use `just` for running checks (`just all`, `just security`), and includes a `release` job using `cycjimmy/semantic-release-action@v4` to automate npm and Docker Hub publishing on pushes to `main`.
## Planned Features (⏳)
- **Handler Unit Tests:** Complete unit tests for all new handlers (mocking services).
- **Refactor Helpers:** Move duplicated helpers (`formatResults`, `isOpenAPIV3`) from handlers to `handler-utils.ts`. (Deferred during refactor).
- **Security Validation (✓):** Implemented Map-based validation helpers in `handler-utils.ts` and refactored handlers/rendering classes to resolve object injection warnings.
- **Completion Logic (✓):** Implemented `complete` callbacks in `ResourceTemplate` definitions within `src/index.ts` for `{field}`, `{path}`, `{method*}`, `{type}`, and conditionally for `{name*}`. Added E2E tests using `client.complete()`.
- **Reference Traversal:** Service to resolve `$ref` URIs (e.g., follow `openapi://components/schemas/Task` from an endpoint detail).
- **Enhanced Component Support:** Ensure all component types listed in `VALID_COMPONENT_TYPES` are fully handled if present in spec. (Reference transformation now supports all types).
- **Parameter Validation:** Add validation logic if needed. (Current Map-based approach handles key validation).
- **Further Token Optimizations:** Explore more ways to reduce token usage in list/detail views.
- **README Enhancements:** Add details on release process, secrets/vars setup. (Partially done).
## Technical Improvements (Ongoing)
1. Code Quality
- OOP design for rendering.
- Clear separation of concerns (Rendering vs. Handling vs. Services).
- Improved type safety in rendering/handling logic.
2. Testing
- Unit tests added for rendering logic.
- Unit tests updated for URI builder, reference transformer, and path item rendering.
- E2E tests updated for new structure and complex fixture. Added tests for resource completion.
- Unit tests for `SpecLoaderService` updated for `swagger2openapi`.
- CI workflow updated to use `just` and includes automated release job for npm and Docker.
- Handler unit tests updated to accommodate `@modelcontextprotocol/sdk` v1.11.0 changes (`RequestHandlerExtra` now requires `requestId`, and `RequestId` import path corrected to `@modelcontextprotocol/sdk/types.js`).
3. API Design
- New URI structure implemented, aligned with OpenAPI spec.
- Consistent list/detail pattern via rendering layer.
- Server now accepts remote URLs and Swagger v2.0 specs via CLI argument.
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; // Import ResourceTemplate
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { OpenAPI } from 'openapi-types'; // Import OpenAPIV3 as well
import { loadConfig } from './config.js';
// Import new handlers
import { TopLevelFieldHandler } from './handlers/top-level-field-handler.js';
import { PathItemHandler } from './handlers/path-item-handler.js';
import { OperationHandler } from './handlers/operation-handler.js';
import { ComponentMapHandler } from './handlers/component-map-handler.js';
import { ComponentDetailHandler } from './handlers/component-detail-handler.js';
import { OpenAPITransformer, ReferenceTransformService } from './services/reference-transform.js';
import { SpecLoaderService } from './services/spec-loader.js';
import { createFormatter } from './services/formatters.js';
import { encodeUriPathComponent } from './utils/uri-builder.js'; // Import specific function
import { isOpenAPIV3, getValidatedComponentMap } from './handlers/handler-utils.js'; // Import type guard and helper
import { VERSION } from './version.js'; // Import the generated version
async function main(): Promise<void> {
try {
// Get spec path and options from command line arguments
const [, , specPath, ...args] = process.argv;
const options = {
outputFormat: args.includes('--output-format')
? args[args.indexOf('--output-format') + 1]
: undefined,
};
// Load configuration
const config = loadConfig(specPath, options);
// Initialize services
const referenceTransform = new ReferenceTransformService();
referenceTransform.registerTransformer('openapi', new OpenAPITransformer());
const specLoader = new SpecLoaderService(config.specPath, referenceTransform);
await specLoader.loadSpec();
// Get the loaded spec to extract the title
const spec: OpenAPI.Document = await specLoader.getSpec(); // Rename back to spec
// Get the transformed spec for use in completions
const transformedSpec: OpenAPI.Document = await specLoader.getTransformedSpec({
resourceType: 'schema', // Use a default context
format: 'openapi',
});
const defaultServerName = 'OpenAPI Schema Explorer';
// Use original spec for title
const serverName = spec.info?.title
? `Schema Explorer for ${spec.info.title}`
: defaultServerName;
// Brief help content for LLMs
const helpContent = `Use resorces/templates/list to get a list of available resources. Use openapi://paths to get a list of all endpoints.`;
// Create MCP server with dynamic name
const server = new McpServer(
{
name: serverName,
version: VERSION, // Use the imported version
},
{
instructions: helpContent,
}
);
// Set up formatter and new handlers
const formatter = createFormatter(config.outputFormat);
const topLevelFieldHandler = new TopLevelFieldHandler(specLoader, formatter);
const pathItemHandler = new PathItemHandler(specLoader, formatter);
const operationHandler = new OperationHandler(specLoader, formatter);
const componentMapHandler = new ComponentMapHandler(specLoader, formatter);
const componentDetailHandler = new ComponentDetailHandler(specLoader, formatter);
// --- Define Resource Templates and Register Handlers ---
// Helper to get dynamic field list for descriptions
const getFieldList = (): string => Object.keys(transformedSpec).join(', ');
// Helper to get dynamic component type list for descriptions
const getComponentTypeList = (): string => {
if (isOpenAPIV3(transformedSpec) && transformedSpec.components) {
return Object.keys(transformedSpec.components).join(', ');
}
return ''; // Return empty if no components or not V3
};
// 1. openapi://{field}
const fieldTemplate = new ResourceTemplate('openapi://{field}', {
list: undefined, // List is handled by the handler logic based on field value
complete: {
field: (): string[] => Object.keys(transformedSpec), // Use transformedSpec
},
});
server.resource(
'openapi-field', // Unique ID for the resource registration
fieldTemplate,
{
// MimeType varies (text/plain for lists, JSON/YAML for details)
description: `Access top-level fields like ${getFieldList()}. (e.g., openapi://info)`,
name: 'OpenAPI Field/List', // Generic name
},
topLevelFieldHandler.handleRequest
);
// 2. openapi://paths/{path}
const pathTemplate = new ResourceTemplate('openapi://paths/{path}', {
list: undefined, // List is handled by the handler
complete: {
path: (): string[] => Object.keys(transformedSpec.paths ?? {}).map(encodeUriPathComponent), // Use imported function directly
},
});
server.resource(
'openapi-path-methods',
pathTemplate,
{
mimeType: 'text/plain', // This always returns a list
description:
'List methods for a specific path (URL encode paths with slashes). (e.g., openapi://paths/users%2F%7Bid%7D)',
name: 'Path Methods List',
},
pathItemHandler.handleRequest
);
// 3. openapi://paths/{path}/{method*}
const operationTemplate = new ResourceTemplate('openapi://paths/{path}/{method*}', {
list: undefined, // Detail view handled by handler
complete: {
path: (): string[] => Object.keys(transformedSpec.paths ?? {}).map(encodeUriPathComponent), // Use imported function directly
method: (): string[] => [
// Provide static list of common methods
'GET',
'POST',
'PUT',
'DELETE',
'PATCH',
'OPTIONS',
'HEAD',
'TRACE',
],
},
});
server.resource(
'openapi-operation-detail',
operationTemplate,
{
mimeType: formatter.getMimeType(), // Detail view uses formatter
description:
'Get details for one or more operations (comma-separated). (e.g., openapi://paths/users%2F%7Bid%7D/get,post)',
name: 'Operation Detail',
},
operationHandler.handleRequest
);
// 4. openapi://components/{type}
const componentMapTemplate = new ResourceTemplate('openapi://components/{type}', {
list: undefined, // List is handled by the handler
complete: {
type: (): string[] => {
// Use type guard to ensure spec is V3 before accessing components
if (isOpenAPIV3(transformedSpec)) {
return Object.keys(transformedSpec.components ?? {});
}
return []; // Return empty array if not V3 (shouldn't happen ideally)
},
},
});
server.resource(
'openapi-component-list',
componentMapTemplate,
{
mimeType: 'text/plain', // This always returns a list
description: `List components of a specific type like ${getComponentTypeList()}. (e.g., openapi://components/schemas)`,
name: 'Component List',
},
componentMapHandler.handleRequest
);
// 5. openapi://components/{type}/{name*}
const componentDetailTemplate = new ResourceTemplate('openapi://components/{type}/{name*}', {
list: undefined, // Detail view handled by handler
complete: {
type: (): string[] => {
// Use type guard to ensure spec is V3 before accessing components
if (isOpenAPIV3(transformedSpec)) {
return Object.keys(transformedSpec.components ?? {});
}
return []; // Return empty array if not V3
},
name: (): string[] => {
// Provide names only if there's exactly one component type defined
if (
isOpenAPIV3(transformedSpec) &&
transformedSpec.components &&
Object.keys(transformedSpec.components).length === 1
) {
// Get the single component type key (e.g., 'schemas')
const componentTypeKey = Object.keys(transformedSpec.components)[0];
// Use the helper to safely get the map
try {
const componentTypeMap = getValidatedComponentMap(transformedSpec, componentTypeKey);
return Object.keys(componentTypeMap);
} catch (error) {
// Should not happen if key came from Object.keys, but handle defensively
console.error(`Error getting component map for key ${componentTypeKey}:`, error);
return [];
}
}
// Otherwise, return no completions for name
return [];
},
},
});
server.resource(
'openapi-component-detail',
componentDetailTemplate,
{
mimeType: formatter.getMimeType(), // Detail view uses formatter
description:
'Get details for one or more components (comma-separated). (e.g., openapi://components/schemas/User,Task)',
name: 'Component Detail',
},
componentDetailHandler.handleRequest
);
// Start server
const transport = new StdioServerTransport();
await server.connect(transport);
} catch (error) {
console.error(
'Failed to start server:',
error instanceof Error ? error.message : String(error)
);
process.exit(1);
}
}
// Run the server
main().catch(error => {
console.error('Unhandled error:', error instanceof Error ? error.message : String(error));
process.exit(1);
});
```
--------------------------------------------------------------------------------
/test/__tests__/unit/handlers/handler-utils.test.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIV3 } from 'openapi-types';
import {
getValidatedPathItem,
getValidatedOperations,
getValidatedComponentMap,
getValidatedComponentDetails,
// We might also test formatResults and isOpenAPIV3 if needed, but focus on new helpers first
} from '../../../../src/handlers/handler-utils.js'; // Adjust path as needed
// --- Mocks and Fixtures ---
const mockSpec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/users': {
get: { responses: { '200': { description: 'OK' } } },
post: { responses: { '201': { description: 'Created' } } },
},
'/users/{id}': {
get: { responses: { '200': { description: 'OK' } } },
delete: { responses: { '204': { description: 'No Content' } } },
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
},
'/items': {
// Path item with no standard methods
description: 'Collection of items',
parameters: [],
},
},
components: {
schemas: {
User: { type: 'object', properties: { id: { type: 'string' } } },
Error: { type: 'object', properties: { message: { type: 'string' } } },
},
responses: {
NotFound: { description: 'Resource not found' },
},
// Intentionally missing 'parameters' section for testing
},
};
const mockSpecNoPaths: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {}, // Empty paths
components: {},
};
const mockSpecNoComponents: OpenAPIV3.Document = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: { '/ping': { get: { responses: { '200': { description: 'OK' } } } } },
// No components section
};
// --- Tests ---
describe('Handler Utils', () => {
// --- getValidatedPathItem ---
describe('getValidatedPathItem', () => {
it('should return the path item object for a valid path', () => {
const pathItem = getValidatedPathItem(mockSpec, '/users');
expect(pathItem).toBeDefined();
expect(pathItem).toHaveProperty('get');
expect(pathItem).toHaveProperty('post');
});
it('should return the path item object for a path with parameters', () => {
const pathItem = getValidatedPathItem(mockSpec, '/users/{id}');
expect(pathItem).toBeDefined();
expect(pathItem).toHaveProperty('get');
expect(pathItem).toHaveProperty('delete');
expect(pathItem).toHaveProperty('parameters');
});
it('should throw Error if path is not found', () => {
expect(() => getValidatedPathItem(mockSpec, '/nonexistent')).toThrow(
new Error('Path "/nonexistent" not found in the specification.')
);
});
it('should throw Error if spec has no paths object', () => {
const specWithoutPaths = { ...mockSpec, paths: undefined };
// @ts-expect-error - Intentionally passing spec with undefined paths to test error handling
expect(() => getValidatedPathItem(specWithoutPaths, '/users')).toThrow(
new Error('Specification does not contain any paths.')
);
});
it('should throw Error if spec has empty paths object', () => {
expect(() => getValidatedPathItem(mockSpecNoPaths, '/users')).toThrow(
new Error('Path "/users" not found in the specification.')
);
});
});
// --- getValidatedOperations ---
describe('getValidatedOperations', () => {
const usersPathItem = mockSpec.paths['/users'] as OpenAPIV3.PathItemObject;
const userIdPathItem = mockSpec.paths['/users/{id}'] as OpenAPIV3.PathItemObject;
const itemsPathItem = mockSpec.paths['/items'] as OpenAPIV3.PathItemObject;
it('should return valid requested methods when all exist', () => {
const validMethods = getValidatedOperations(usersPathItem, ['get', 'post'], '/users');
expect(validMethods).toEqual(['get', 'post']);
});
it('should return valid requested methods when some exist', () => {
const validMethods = getValidatedOperations(usersPathItem, ['get', 'put', 'post'], '/users');
expect(validMethods).toEqual(['get', 'post']);
});
it('should return valid requested methods ignoring case', () => {
const validMethods = getValidatedOperations(usersPathItem, ['GET', 'POST'], '/users');
// Note: the helper expects lowercase input, but the internal map uses lowercase keys
expect(validMethods).toEqual(['GET', 'POST']); // It returns the original case of valid inputs
});
it('should return only the valid method when one exists', () => {
const validMethods = getValidatedOperations(
userIdPathItem,
['delete', 'patch'],
'/users/{id}'
);
expect(validMethods).toEqual(['delete']);
});
it('should throw Error if no requested methods are valid', () => {
expect(() => getValidatedOperations(usersPathItem, ['put', 'delete'], '/users')).toThrow(
new Error(
'None of the requested methods (put, delete) are valid for path "/users". Available methods: get, post'
)
);
});
it('should throw Error if requested methods array is empty', () => {
// The calling handler should prevent this, but test the helper
expect(() => getValidatedOperations(usersPathItem, [], '/users')).toThrow(
new Error(
'None of the requested methods () are valid for path "/users". Available methods: get, post'
)
);
});
it('should throw Error if path item has no valid methods', () => {
expect(() => getValidatedOperations(itemsPathItem, ['get'], '/items')).toThrow(
new Error(
'None of the requested methods (get) are valid for path "/items". Available methods: '
)
);
});
});
// --- getValidatedComponentMap ---
describe('getValidatedComponentMap', () => {
it('should return the component map for a valid type', () => {
const schemasMap = getValidatedComponentMap(mockSpec, 'schemas');
expect(schemasMap).toBeDefined();
expect(schemasMap).toHaveProperty('User');
expect(schemasMap).toHaveProperty('Error');
});
it('should return the component map for another valid type', () => {
const responsesMap = getValidatedComponentMap(mockSpec, 'responses');
expect(responsesMap).toBeDefined();
expect(responsesMap).toHaveProperty('NotFound');
});
it('should throw Error if component type is not found', () => {
expect(() => getValidatedComponentMap(mockSpec, 'parameters')).toThrow(
new Error(
'Component type "parameters" not found in the specification. Available types: schemas, responses'
)
);
});
it('should throw Error if spec has no components section', () => {
expect(() => getValidatedComponentMap(mockSpecNoComponents, 'schemas')).toThrow(
new Error('Specification does not contain a components section.')
);
});
});
// --- getValidatedComponentDetails ---
describe('getValidatedComponentDetails', () => {
const schemasMap = mockSpec.components?.schemas as Record<string, OpenAPIV3.SchemaObject>;
const responsesMap = mockSpec.components?.responses as Record<string, OpenAPIV3.ResponseObject>;
const detailsMapSchemas = new Map(Object.entries(schemasMap));
const detailsMapResponses = new Map(Object.entries(responsesMap));
it('should return details for valid requested names', () => {
const validDetails = getValidatedComponentDetails(
detailsMapSchemas,
['User', 'Error'],
'schemas'
);
expect(validDetails).toHaveLength(2);
expect(validDetails[0].name).toBe('User');
expect(validDetails[0].detail).toEqual(schemasMap['User']);
expect(validDetails[1].name).toBe('Error');
expect(validDetails[1].detail).toEqual(schemasMap['Error']);
});
it('should return details for a single valid requested name', () => {
const validDetails = getValidatedComponentDetails(detailsMapSchemas, ['User'], 'schemas');
expect(validDetails).toHaveLength(1);
expect(validDetails[0].name).toBe('User');
expect(validDetails[0].detail).toEqual(schemasMap['User']);
});
it('should return only details for valid names when some are invalid', () => {
const validDetails = getValidatedComponentDetails(
detailsMapSchemas,
['User', 'NonExistent', 'Error'],
'schemas'
);
expect(validDetails).toHaveLength(2);
expect(validDetails[0].name).toBe('User');
expect(validDetails[1].name).toBe('Error');
});
it('should throw Error if no requested names are valid', () => {
expect(() =>
getValidatedComponentDetails(detailsMapSchemas, ['NonExistent1', 'NonExistent2'], 'schemas')
).toThrow(
new Error(
// Expect sorted names: Error, User
'None of the requested names (NonExistent1, NonExistent2) are valid for component type "schemas". Available names: Error, User'
)
);
});
it('should throw Error if requested names array is empty', () => {
// The calling handler should prevent this, but test the helper
expect(() => getValidatedComponentDetails(detailsMapSchemas, [], 'schemas')).toThrow(
new Error(
// Expect sorted names: Error, User
'None of the requested names () are valid for component type "schemas". Available names: Error, User'
)
);
});
it('should work for other component types (responses)', () => {
const validDetails = getValidatedComponentDetails(
detailsMapResponses,
['NotFound'],
'responses'
);
expect(validDetails).toHaveLength(1);
expect(validDetails[0].name).toBe('NotFound');
expect(validDetails[0].detail).toEqual(responsesMap['NotFound']);
});
});
});
```
--------------------------------------------------------------------------------
/test/__tests__/e2e/format.test.ts:
--------------------------------------------------------------------------------
```typescript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { startMcpServer, McpTestContext } from '../../utils/mcp-test-helpers.js'; // Import McpTestContext
import { load as yamlLoad } from 'js-yaml';
// Remove old test types/guards if not needed, or adapt them
// import { isEndpointErrorResponse } from '../../utils/test-types.js';
// import type { EndpointResponse, ResourceResponse } from '../../utils/test-types.js';
// Import specific SDK types needed
import { ReadResourceResult, TextResourceContents } from '@modelcontextprotocol/sdk/types.js';
// Generic type guard for simple object check
function isObject(obj: unknown): obj is Record<string, unknown> {
return typeof obj === 'object' && obj !== null;
}
// Type guard to check if content is TextResourceContents
function hasTextContent(
content: ReadResourceResult['contents'][0]
): content is TextResourceContents {
// Check for the 'text' property specifically and ensure it's not undefined
return content && typeof (content as TextResourceContents).text === 'string';
}
function parseJson(text: string | undefined): unknown {
if (text === undefined) throw new Error('Cannot parse undefined text');
return JSON.parse(text);
}
function parseYaml(text: string | undefined): unknown {
if (text === undefined) throw new Error('Cannot parse undefined text');
const result = yamlLoad(text);
if (result === undefined) {
throw new Error('Invalid YAML: parsing resulted in undefined');
}
return result;
}
function safeParse(text: string | undefined, format: 'json' | 'yaml'): unknown {
try {
return format === 'json' ? parseJson(text) : parseYaml(text);
} catch (error) {
throw new Error(
`Failed to parse ${format} content: ${error instanceof Error ? error.message : String(error)}`
);
}
}
// Removed old parseEndpointResponse
describe('Output Format E2E', () => {
let testContext: McpTestContext;
let client: Client;
afterEach(async () => {
await testContext?.cleanup();
});
describe('JSON format (default)', () => {
beforeEach(async () => {
testContext = await startMcpServer('test/fixtures/complex-endpoint.json', {
outputFormat: 'json',
});
client = testContext.client;
});
it('should return JSON for openapi://info', async () => {
const result = await client.readResource({ uri: 'openapi://info' });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.mimeType).toBe('application/json');
if (!hasTextContent(content)) throw new Error('Expected text content'); // Add guard
expect(() => safeParse(content.text, 'json')).not.toThrow();
const data = safeParse(content.text, 'json');
expect(isObject(data) && data['title']).toBe('Complex Endpoint Test API'); // Use bracket notation after guard
});
it('should return JSON for operation detail', async () => {
const path = encodeURIComponent('api/v1/organizations/{orgId}/projects/{projectId}/tasks');
const result = await client.readResource({ uri: `openapi://paths/${path}/get` });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.mimeType).toBe('application/json');
if (!hasTextContent(content)) throw new Error('Expected text content'); // Add guard
expect(() => safeParse(content.text, 'json')).not.toThrow();
const data = safeParse(content.text, 'json');
expect(isObject(data) && data['operationId']).toBe('getProjectTasks'); // Use bracket notation after guard
});
it('should return JSON for component detail', async () => {
const result = await client.readResource({ uri: 'openapi://components/schemas/Task' });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.mimeType).toBe('application/json');
if (!hasTextContent(content)) throw new Error('Expected text content'); // Add guard
expect(() => safeParse(content.text, 'json')).not.toThrow();
const data = safeParse(content.text, 'json');
expect(isObject(data) && data['type']).toBe('object'); // Use bracket notation after guard
expect(
isObject(data) &&
isObject(data['properties']) &&
isObject(data['properties']['id']) &&
data['properties']['id']['type']
).toBe('string'); // Use bracket notation with type checking
});
});
describe('YAML format', () => {
beforeEach(async () => {
testContext = await startMcpServer('test/fixtures/complex-endpoint.json', {
outputFormat: 'yaml',
});
client = testContext.client;
});
it('should return YAML for openapi://info', async () => {
const result = await client.readResource({ uri: 'openapi://info' });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.mimeType).toBe('text/yaml');
if (!hasTextContent(content)) throw new Error('Expected text content'); // Add guard
expect(() => safeParse(content.text, 'yaml')).not.toThrow();
expect(content.text).toContain('title: Complex Endpoint Test API');
expect(content.text).toMatch(/\n$/);
});
it('should return YAML for operation detail', async () => {
const path = encodeURIComponent('api/v1/organizations/{orgId}/projects/{projectId}/tasks');
const result = await client.readResource({ uri: `openapi://paths/${path}/get` });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.mimeType).toBe('text/yaml');
if (!hasTextContent(content)) throw new Error('Expected text content'); // Add guard
expect(() => safeParse(content.text, 'yaml')).not.toThrow();
expect(content.text).toContain('operationId: getProjectTasks');
expect(content.text).toMatch(/\n$/);
});
it('should return YAML for component detail', async () => {
const result = await client.readResource({ uri: 'openapi://components/schemas/Task' });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.mimeType).toBe('text/yaml');
if (!hasTextContent(content)) throw new Error('Expected text content'); // Add guard
expect(() => safeParse(content.text, 'yaml')).not.toThrow();
expect(content.text).toContain('type: object');
expect(content.text).toContain('properties:');
expect(content.text).toContain('id:');
expect(content.text).toMatch(/\n$/);
});
// Note: The test for listResourceTemplates is removed as it tested old template structure.
// We could add a new test here if needed, but the mimeType for templates isn't explicitly set anymore.
it('should handle errors in YAML format (e.g., invalid component name)', async () => {
const result = await client.readResource({ uri: 'openapi://components/schemas/InvalidName' });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
// Errors are always text/plain, regardless of configured output format
expect(content.mimeType).toBe('text/plain');
expect(content.isError).toBe(true);
if (!hasTextContent(content)) throw new Error('Expected text');
// Updated error message from getValidatedComponentDetails with sorted names
expect(content.text).toContain(
'None of the requested names (InvalidName) are valid for component type "schemas". Available names: CreateTaskRequest, Task, TaskList'
);
});
});
describe('Minified JSON format', () => {
beforeEach(async () => {
testContext = await startMcpServer('test/fixtures/complex-endpoint.json', {
outputFormat: 'json-minified',
});
client = testContext.client;
});
it('should return minified JSON for openapi://info', async () => {
const result = await client.readResource({ uri: 'openapi://info' });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.mimeType).toBe('application/json');
if (!hasTextContent(content)) throw new Error('Expected text content');
expect(() => safeParse(content.text, 'json')).not.toThrow();
const data = safeParse(content.text, 'json');
expect(isObject(data) && data['title']).toBe('Complex Endpoint Test API');
// Check for lack of pretty-printing whitespace
expect(content.text).not.toContain('\n ');
expect(content.text).not.toContain(' '); // Double check no indentation
});
it('should return minified JSON for operation detail', async () => {
const path = encodeURIComponent('api/v1/organizations/{orgId}/projects/{projectId}/tasks');
const result = await client.readResource({ uri: `openapi://paths/${path}/get` });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.mimeType).toBe('application/json');
if (!hasTextContent(content)) throw new Error('Expected text content');
expect(() => safeParse(content.text, 'json')).not.toThrow();
const data = safeParse(content.text, 'json');
expect(isObject(data) && data['operationId']).toBe('getProjectTasks');
// Check for lack of pretty-printing whitespace
expect(content.text).not.toContain('\n ');
expect(content.text).not.toContain(' ');
});
it('should return minified JSON for component detail', async () => {
const result = await client.readResource({ uri: 'openapi://components/schemas/Task' });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.mimeType).toBe('application/json');
if (!hasTextContent(content)) throw new Error('Expected text content');
expect(() => safeParse(content.text, 'json')).not.toThrow();
const data = safeParse(content.text, 'json');
expect(isObject(data) && data['type']).toBe('object');
expect(
isObject(data) &&
isObject(data['properties']) &&
isObject(data['properties']['id']) &&
data['properties']['id']['type']
).toBe('string');
// Check for lack of pretty-printing whitespace
expect(content.text).not.toContain('\n ');
expect(content.text).not.toContain(' ');
});
});
});
```
--------------------------------------------------------------------------------
/test/__tests__/unit/rendering/components.test.ts:
--------------------------------------------------------------------------------
```typescript
import { OpenAPIV3 } from 'openapi-types';
import { RenderableComponents, RenderableComponentMap } from '../../../../src/rendering/components';
import { RenderContext } from '../../../../src/rendering/types';
import { IFormatter, JsonFormatter } from '../../../../src/services/formatters';
// Mock Formatter & Context
const mockFormatter: IFormatter = new JsonFormatter();
const mockContext: RenderContext = {
formatter: mockFormatter,
baseUri: 'openapi://',
};
// Sample Components Object Fixture
const sampleComponents: OpenAPIV3.ComponentsObject = {
schemas: {
User: { type: 'object', properties: { name: { type: 'string' } } },
Error: { type: 'object', properties: { message: { type: 'string' } } },
},
parameters: {
userIdParam: { name: 'userId', in: 'path', required: true, schema: { type: 'integer' } },
},
responses: {
NotFound: { description: 'Resource not found' },
},
// Intentionally empty type
examples: {},
// Intentionally missing type
// securitySchemes: {}
};
const emptyComponents: OpenAPIV3.ComponentsObject = {};
describe('RenderableComponents (List Types)', () => {
it('should list available component types correctly', () => {
const renderable = new RenderableComponents(sampleComponents);
const result = renderable.renderList(mockContext);
expect(result).toHaveLength(1);
expect(result[0].uriSuffix).toBe('components');
expect(result[0].renderAsList).toBe(true);
expect(result[0].isError).toBeUndefined();
expect(result[0].data).toContain('Available Component Types:');
// Check sorted types with descriptions
expect(result[0].data).toMatch(/-\s+examples: Reusable examples of media type payloads\n/);
expect(result[0].data).toMatch(
/-\s+parameters: Reusable request parameters \(query, path, header, cookie\)\n/
);
expect(result[0].data).toMatch(/-\s+responses: Reusable API responses\n/);
expect(result[0].data).toMatch(/-\s+schemas: Reusable data structures \(models\)\n/);
expect(result[0].data).not.toContain('- securitySchemes'); // Missing type
// Check hint with example
expect(result[0].data).toContain(
"Hint: Use 'openapi://components/{type}' to view details for a specific component type. (e.g., openapi://components/examples)"
);
});
it('should handle empty components object', () => {
const renderable = new RenderableComponents(emptyComponents);
const result = renderable.renderList(mockContext);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
uriSuffix: 'components',
isError: true, // Changed expectation: should be an error if no components
errorText: 'No components found in the specification.',
renderAsList: true,
});
});
it('should handle components object with no valid types', () => {
// Create object with only an extension property but no valid component types
const invalidComponents = { 'x-custom': {} } as OpenAPIV3.ComponentsObject;
const renderable = new RenderableComponents(invalidComponents);
const result = renderable.renderList(mockContext);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
uriSuffix: 'components',
isError: true,
errorText: 'No valid component types found.',
renderAsList: true,
});
});
it('should handle undefined components object', () => {
const renderable = new RenderableComponents(undefined);
const result = renderable.renderList(mockContext);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
uriSuffix: 'components',
isError: true,
errorText: 'No components found in the specification.',
renderAsList: true,
});
});
it('renderDetail should delegate to renderList', () => {
const renderable = new RenderableComponents(sampleComponents);
const listResult = renderable.renderList(mockContext);
const detailResult = renderable.renderDetail(mockContext);
expect(detailResult).toEqual(listResult);
});
it('getComponentMap should return correct map', () => {
const renderable = new RenderableComponents(sampleComponents);
expect(renderable.getComponentMap('schemas')).toBe(sampleComponents.schemas);
expect(renderable.getComponentMap('parameters')).toBe(sampleComponents.parameters);
expect(renderable.getComponentMap('examples')).toBe(sampleComponents.examples);
expect(renderable.getComponentMap('securitySchemes')).toBeUndefined();
});
});
describe('RenderableComponentMap (List/Detail Names)', () => {
const schemasMap = sampleComponents.schemas;
const parametersMap = sampleComponents.parameters;
const emptyMap = sampleComponents.examples;
const schemasUriSuffix = 'components/schemas';
const paramsUriSuffix = 'components/parameters';
describe('renderList (List Names)', () => {
it('should list component names correctly (schemas)', () => {
const renderable = new RenderableComponentMap(schemasMap, 'schemas', schemasUriSuffix);
const result = renderable.renderList(mockContext);
expect(result).toHaveLength(1);
expect(result[0].uriSuffix).toBe(schemasUriSuffix);
expect(result[0].renderAsList).toBe(true);
expect(result[0].isError).toBeUndefined();
expect(result[0].data).toContain('Available schemas:');
expect(result[0].data).toMatch(/-\s+Error\n/); // Sorted
expect(result[0].data).toMatch(/-\s+User\n/);
// Check hint with example
expect(result[0].data).toContain(
"Hint: Use 'openapi://components/schemas/{name}' to view details for a specific schema. (e.g., openapi://components/schemas/Error)"
);
});
it('should list component names correctly (parameters)', () => {
const renderable = new RenderableComponentMap(parametersMap, 'parameters', paramsUriSuffix);
const result = renderable.renderList(mockContext);
expect(result).toHaveLength(1);
expect(result[0].uriSuffix).toBe(paramsUriSuffix);
expect(result[0].data).toContain('Available parameters:');
expect(result[0].data).toMatch(/-\s+userIdParam\n/);
// Check hint with example
expect(result[0].data).toContain(
"Hint: Use 'openapi://components/parameters/{name}' to view details for a specific parameter. (e.g., openapi://components/parameters/userIdParam)"
);
});
it('should handle empty component map', () => {
const renderable = new RenderableComponentMap(emptyMap, 'examples', 'components/examples');
const result = renderable.renderList(mockContext);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
uriSuffix: 'components/examples',
isError: true,
errorText: 'No components of type "examples" found.',
renderAsList: true,
});
});
it('should handle undefined component map', () => {
const renderable = new RenderableComponentMap(
undefined,
'securitySchemes',
'components/securitySchemes'
);
const result = renderable.renderList(mockContext);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
uriSuffix: 'components/securitySchemes',
isError: true,
errorText: 'No components of type "securitySchemes" found.',
renderAsList: true,
});
});
});
describe('renderComponentDetail (Get Component Detail)', () => {
it('should return detail for a single valid component', () => {
const renderable = new RenderableComponentMap(schemasMap, 'schemas', schemasUriSuffix);
const result = renderable.renderComponentDetail(mockContext, ['User']);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
uriSuffix: `${schemasUriSuffix}/User`,
data: schemasMap?.User, // Expect raw component object
});
});
it('should return details for multiple valid components', () => {
const renderable = new RenderableComponentMap(schemasMap, 'schemas', schemasUriSuffix);
const result = renderable.renderComponentDetail(mockContext, ['Error', 'User']);
expect(result).toHaveLength(2);
expect(result).toContainEqual({
uriSuffix: `${schemasUriSuffix}/Error`,
data: schemasMap?.Error,
});
expect(result).toContainEqual({
uriSuffix: `${schemasUriSuffix}/User`,
data: schemasMap?.User,
});
});
it('should return error for non-existent component', () => {
const renderable = new RenderableComponentMap(schemasMap, 'schemas', schemasUriSuffix);
const result = renderable.renderComponentDetail(mockContext, ['NonExistent']);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
uriSuffix: `${schemasUriSuffix}/NonExistent`,
data: null,
isError: true,
errorText: 'Component "NonExistent" of type "schemas" not found.',
renderAsList: true,
});
});
it('should handle mix of valid and invalid components', () => {
const renderable = new RenderableComponentMap(schemasMap, 'schemas', schemasUriSuffix);
const result = renderable.renderComponentDetail(mockContext, ['User', 'Invalid']);
expect(result).toHaveLength(2);
expect(result).toContainEqual({
uriSuffix: `${schemasUriSuffix}/User`,
data: schemasMap?.User,
});
expect(result).toContainEqual({
uriSuffix: `${schemasUriSuffix}/Invalid`,
data: null,
isError: true,
errorText: 'Component "Invalid" of type "schemas" not found.',
renderAsList: true,
});
});
it('should return error if component map is undefined', () => {
const renderable = new RenderableComponentMap(
undefined,
'securitySchemes',
'components/securitySchemes'
);
const result = renderable.renderComponentDetail(mockContext, ['apiKey']);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
uriSuffix: 'components/securitySchemes/apiKey',
data: null,
isError: true,
errorText: 'Component map for type "securitySchemes" not found.',
renderAsList: true,
});
});
});
describe('renderDetail (Interface Method)', () => {
it('should delegate to renderList', () => {
const renderable = new RenderableComponentMap(schemasMap, 'schemas', schemasUriSuffix);
const listResult = renderable.renderList(mockContext);
const detailResult = renderable.renderDetail(mockContext);
expect(detailResult).toEqual(listResult);
});
});
describe('getComponent', () => {
it('should return correct component object', () => {
const renderable = new RenderableComponentMap(schemasMap, 'schemas', schemasUriSuffix);
expect(renderable.getComponent('User')).toBe(schemasMap?.User);
expect(renderable.getComponent('Error')).toBe(schemasMap?.Error);
});
it('should return undefined for non-existent component', () => {
const renderable = new RenderableComponentMap(schemasMap, 'schemas', schemasUriSuffix);
expect(renderable.getComponent('NonExistent')).toBeUndefined();
});
it('should return undefined if component map is undefined', () => {
const renderable = new RenderableComponentMap(
undefined,
'securitySchemes',
'components/securitySchemes'
);
expect(renderable.getComponent('apiKey')).toBeUndefined();
});
});
});
```
--------------------------------------------------------------------------------
/memory-bank/systemPatterns.md:
--------------------------------------------------------------------------------
```markdown
# System Patterns
## Architecture Overview
```mermaid
graph TD
CliArg[CLI Argument (Path/URL)] --> Config[src/config.ts]
Config --> Server[MCP Server]
CliArg --> SpecLoader[Spec Loader Service]
SpecLoader -- Uses --> S2OLib[swagger2openapi Lib]
SpecLoader --> Transform[Reference Transform Service]
Transform --> Handlers[Resource Handlers]
Server --> Handlers
subgraph Services
SpecLoader
Transform
S2OLib
end
subgraph Handlers
TopLevelFieldHandler[TopLevelField Handler (openapi://{field})]
PathItemHandler[PathItem Handler (openapi://paths/{path})]
OperationHandler[Operation Handler (openapi://paths/{path}/{method*})]
ComponentMapHandler[ComponentMap Handler (openapi://components/{type})]
ComponentDetailHandler[ComponentDetail Handler (openapi://components/{type}/{name*})]
end
subgraph Formatters
JsonFormatter[Json Formatter]
YamlFormatter[Yaml Formatter]
MinifiedJsonFormatter[Minified Json Formatter]
end
subgraph Rendering (OOP)
RenderableDocument[RenderableDocument]
RenderablePaths[RenderablePaths]
RenderablePathItem[RenderablePathItem]
RenderableComponents[RenderableComponents]
RenderableComponentMap[RenderableComponentMap]
RenderUtils[Rendering Utils]
end
Handlers --> Rendering
SpecLoader --> Rendering
subgraph Utils
UriBuilder[URI Builder (src/utils)]
end
UriBuilder --> Transform
UriBuilder --> RenderUtils
```
## Component Structure
### Services Layer
- **SpecLoaderService (`src/services/spec-loader.ts`):**
- Uses `swagger2openapi` library.
- Loads specification from local file path or remote URL provided via CLI argument.
- Handles parsing of JSON/YAML.
- Automatically converts Swagger v2.0 specs to OpenAPI v3.0 objects.
- Provides the resulting OpenAPI v3.0 document object.
- Handles errors during loading/conversion.
- **ReferenceTransformService (`src/services/reference-transform.ts`):**
- Takes the OpenAPI v3.0 document from `SpecLoaderService`.
- Traverses the document and transforms internal references (e.g., `#/components/schemas/MySchema`) into MCP URIs (e.g., `openapi://components/schemas/MySchema`).
- Uses `UriBuilder` utility for consistent URI generation.
- Returns the transformed OpenAPI v3.0 document.
- **Formatters (`src/services/formatters.ts`):**
- Provide implementations for different output formats (JSON, YAML, Minified JSON).
- Used by handlers to serialize detail view responses.
- `IFormatter` interface defines `format()` and `getMimeType()`.
- `createFormatter` function instantiates the correct formatter based on `OutputFormat` type (`json`, `yaml`, `json-minified`).
### Rendering Layer (OOP)
- **Renderable Classes:** Wrapper classes (`RenderableDocument`, `RenderablePaths`, `RenderablePathItem`, `RenderableComponents`, `RenderableComponentMap`) implement `RenderableSpecObject` interface.
- **Interface:** `RenderableSpecObject` defines `renderList()` and `renderDetail()` methods returning `RenderResultItem[]`.
- **RenderResultItem:** Intermediate structure holding data (`unknown`), `uriSuffix`, `isError?`, `errorText?`, `renderAsList?`.
- **RenderContext:** Passed to render methods, contains `formatter` and `baseUri`.
- **Utils:** Helper functions (`getOperationSummary`, `generateListHint`, `createErrorResult`) in `src/rendering/utils.ts`. `generateListHint` now uses centralized URI builder logic.
### Handler Layer
- **Structure:** Separate handlers for each distinct URI pattern/resource type.
- **Responsibilities:**
- Parse URI variables provided by SDK.
- Load/retrieve the transformed spec via `SpecLoaderService`.
- Instantiate appropriate `Renderable*` classes.
- Invoke the correct rendering method (`renderList` or a specific detail method like `renderTopLevelFieldDetail`, `renderOperationDetail`, `renderComponentDetail`).
- Format the `RenderResultItem[]` using `formatResults` from `src/handlers/handler-utils.ts`.
- Construct the final `{ contents: ... }` response object.
- Instantiate `RenderablePathItem` correctly with raw path and built suffix.
- **Handlers:**
- `TopLevelFieldHandler`: Handles `openapi://{field}`. Delegates list rendering for `paths`/`components` to `RenderablePaths`/`RenderableComponents`. Renders details for other fields (`info`, `servers`, etc.) via `RenderableDocument.renderTopLevelFieldDetail`.
- `PathItemHandler`: Handles `openapi://paths/{path}`. Uses `RenderablePathItem.renderList` to list methods. Instantiates `RenderablePathItem` with raw path and built suffix.
- `OperationHandler`: Handles `openapi://paths/{path}/{method*}`. Uses `RenderablePathItem.renderOperationDetail` for operation details. Handles multi-value `method` variable. Instantiates `RenderablePathItem` with raw path and built suffix.
- `ComponentMapHandler`: Handles `openapi://components/{type}`. Uses `RenderableComponentMap.renderList` to list component names.
- `ComponentDetailHandler`: Handles `openapi://components/{type}/{name*}`. Uses `RenderableComponentMap.renderComponentDetail` for component details. Handles multi-value `name` variable.
- **Utils:** Shared functions (`formatResults`, `isOpenAPIV3`, `FormattedResultItem` type, validation helpers) in `src/handlers/handler-utils.ts`.
### Utilities Layer
- **URI Builder (`src/utils/uri-builder.ts`):**
- Centralized functions for building full URIs (`openapi://...`) and URI suffixes.
- Handles encoding of path components (removing leading slash first).
- Used by `ReferenceTransformService` and the rendering layer (`generateListHint`, `Renderable*` classes) to ensure consistency.
### Configuration Layer (`src/config.ts`)
- Parses command-line arguments.
- Expects a single required argument: the path or URL to the specification file.
- Supports an optional `--output-format` argument (`json`, `yaml`, `json-minified`).
- Validates arguments and provides usage instructions on error.
- Creates the `ServerConfig` object used by the server.
## Release Automation (`semantic-release`)
- **Configuration:** Defined in `.releaserc.json`.
- **Workflow:**
1. `@semantic-release/commit-analyzer`: Determines release type from conventional commits.
2. `@semantic-release/release-notes-generator`: Generates release notes.
3. `@semantic-release/changelog`: Updates `CHANGELOG.md`.
4. `@semantic-release/npm`: Updates `version` in `package.json`.
5. `@semantic-release/exec`: Runs `scripts/generate-version.js` to create/update `src/version.ts` with the new version.
6. `@semantic-release/git`: Commits `package.json`, `package-lock.json`, `CHANGELOG.md`, and `src/version.ts`. Creates Git tag.
7. `@codedependant/semantic-release-docker`: Builds the Docker image using `./Dockerfile` and pushes it to Docker Hub (`kadykov/mcp-openapi-schema-explorer`) with `latest` and version tags.
8. `@semantic-release/github`: Creates GitHub Release.
- **Trigger:** Executed by the `release` job in the GitHub Actions workflow (`.github/workflows/ci.yml`) on pushes to the `main` branch, using `cycjimmy/semantic-release-action@v4`.
- **CI Action:** The `cycjimmy/semantic-release-action` handles installing `semantic-release` and the plugins listed in its `extra_plugins` input (`@semantic-release/changelog`, `@semantic-release/exec`, `@semantic-release/git`, `@codedependant/semantic-release-docker`).
- **Docker Environment:** The CI job sets up Docker QEMU, Buildx, and logs into Docker Hub before running the semantic-release action.
- **Versioning:** The server version in `src/index.ts` is dynamically imported from the generated `src/version.ts`. A default `src/version.ts` (with `0.0.0-dev`) is kept in the repository for local builds.
## Resource Design Patterns
### URI Structure (Revised)
- Implicit List/Detail based on path depth.
- Aligned with OpenAPI specification structure.
- **Templates:**
- `openapi://{field}`: Top-level field details (info, servers) or list trigger (paths, components).
- `openapi://paths/{path}`: List methods for a specific path.
- `openapi://paths/{path}/{method*}`: Operation details (supports multiple methods).
- `openapi://components/{type}`: List names for a specific component type.
- `openapi://components/{type}/{name*}`: Component details (supports multiple names).
- **Completions:**
- Defined directly in `src/index.ts` within `ResourceTemplate` definitions passed to `server.resource()`.
- Uses the `transformedSpec` object loaded before server initialization.
- Provides suggestions for `{field}`, `{path}`, `{method*}`, `{type}`.
- Provides suggestions for `{name*}` _only_ if the spec contains exactly one component type.
- **Reference URIs (Corrected):**
- Internal `$ref`s like `#/components/schemas/MySchema` are transformed by `ReferenceTransformService` into resolvable MCP URIs: `openapi://components/schemas/MySchema`.
- This applies to all component types under `#/components/`.
- External references remain unchanged.
### Response Format Patterns
1. **Token-Efficient Lists:**
- `text/plain` format used for all list views (`openapi://paths`, `openapi://components`, `openapi://paths/{path}`, `openapi://components/{type}`).
- Include hints for navigating to detail views, generated via `generateListHint` using the centralized URI builder.
- `openapi://paths` format: `METHOD1 METHOD2 /path`
- `openapi://paths/{path}` format: `METHOD: Summary/OpId`
- `openapi://components` format: `- type`
- `openapi://components/{type}` format: `- name`
2. **Detail Views:**
- Use configured formatter (JSON/YAML/Minified JSON via `IFormatter`).
- Handled by `openapi://{field}` (for non-structural fields), `openapi://paths/{path}/{method*}`, `openapi://components/{type}/{name*}`.
3. **Error Handling:**
- Handlers catch errors and use `createErrorResult` utility.
- `formatResults` utility formats errors into `FormattedResultItem` with `isError: true`, `mimeType: 'text/plain'`, and error message in `text`.
4. **Type Safety:**
- Strong typing with OpenAPI v3 types.
- `Renderable*` classes encapsulate type-specific logic.
- `isOpenAPIV3` type guard used in handlers.
## Extension Points
1. Reference Transformers:
- AsyncAPI transformer
- GraphQL transformer
- Custom format transformers
2. Resource Handlers:
- Schema resource handler
- Additional reference handlers
- Custom format handlers (via `IFormatter` interface)
3. URI Resolution:
- Reference transformation service (`ReferenceTransformService`) handles converting `#/components/{type}/{name}` to `openapi://components/{type}/{name}` URIs during spec loading.
- Cross-resource linking is implicit via generated URIs in hints and transformed refs.
- External references are currently kept as-is.
4. Validation:
- Parameter validation
- Reference validation
- Format-specific validation
## Testing Strategy
1. **Unit Tests:**
- `SpecLoaderService`: Mock `swagger2openapi` to test local/remote and v2/v3 loading logic, including error handling.
- `ReferenceTransformService`: Verify correct transformation of `#/components/...` refs to MCP URIs.
- Rendering Classes (`Renderable*`): Test list and detail rendering logic.
- Handlers: Mock services (`SpecLoaderService`, `IFormatter`) to test URI parsing and delegation to rendering classes.
- `UriBuilder`: Test URI encoding and generation.
2. **E2E Tests:**
- Use `mcp-test-helpers` to start the server with different spec inputs.
- **`spec-loading.test.ts`:** Verify basic resource access (`info`, `paths`, `components`, specific component detail) works correctly when loading:
- Local Swagger v2.0 spec (`test/fixtures/sample-v2-api.json`).
- Remote OpenAPI v3.0 spec (e.g., Petstore URL).
- **`refactored-resources.test.ts`:** Continue to test detailed resource interactions (multi-value params, specific path/method/component combinations, errors) using the primary complex local v3 fixture (`complex-endpoint.json`).
- **`format.test.ts`:** Verify different output formats (JSON/YAML/Minified JSON) work as expected.
- **Completion Tests:** Added to `refactored-resources.test.ts` using `client.complete()` to verify completion logic.
3. **Test Support:**
- Type-safe test utilities (`mcp-test-helpers`). Updated `StartServerOptions` to include `json-minified`.
- Test fixtures for v2.0 and v3.0 specs.
4. **CI Integration (`.github/workflows/ci.yml`):**
- **`test` Job:** Runs on push/PR to `main`. Uses Node 22, installs `just`, runs `npm ci`, then `just all` (format, lint, build, test). Uploads coverage.
- **`security` Job:** Runs on push/PR to `main`. Uses Node 22, installs `just`, runs `npm ci`, then `just security` (audit, licenses). Runs CodeQL analysis separately.
- **`release` Job:** Runs _only_ on push to `main` after `test` and `security` pass. Checks out full history, sets up Docker (QEMU, Buildx, Login), then runs `cycjimmy/semantic-release-action@v4` with necessary `extra_plugins` and environment variables (`GITHUB_TOKEN`, `NPM_TOKEN`, Docker Hub credentials handled by login action).
```
--------------------------------------------------------------------------------
/test/__tests__/e2e/resources.test.ts:
--------------------------------------------------------------------------------
```typescript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
// Import specific SDK types needed
import {
ReadResourceResult,
TextResourceContents,
// Removed unused CompleteRequest, CompleteResult
} from '@modelcontextprotocol/sdk/types.js';
import { startMcpServer, McpTestContext } from '../../utils/mcp-test-helpers';
import path from 'path';
// Use the complex spec for E2E tests
const complexSpecPath = path.resolve(__dirname, '../../fixtures/complex-endpoint.json');
// Helper function to parse JSON safely
function parseJsonSafely(text: string | undefined): unknown {
if (text === undefined) {
throw new Error('Received undefined text for JSON parsing');
}
try {
return JSON.parse(text);
} catch (e) {
console.error('Failed to parse JSON:', text);
throw new Error(`Invalid JSON received: ${e instanceof Error ? e.message : String(e)}`);
}
}
// Type guard to check if content is TextResourceContents
function hasTextContent(
content: ReadResourceResult['contents'][0]
): content is TextResourceContents {
// Check for the 'text' property specifically, differentiating from BlobResourceContents
return typeof (content as TextResourceContents).text === 'string';
}
describe('E2E Tests for Refactored Resources', () => {
let testContext: McpTestContext;
let client: Client; // Use the correct Client type
// Helper to setup client for tests
async function setup(specPath: string = complexSpecPath): Promise<void> {
// Use complex spec by default
testContext = await startMcpServer(specPath, { outputFormat: 'json' }); // Default to JSON
client = testContext.client; // Get client from helper context
// Initialization is handled by startMcpServer connecting the transport
}
afterEach(async () => {
await testContext?.cleanup(); // Use cleanup function from helper
});
// Helper to read resource and perform basic checks
async function readResourceAndCheck(uri: string): Promise<ReadResourceResult['contents'][0]> {
const result = await client.readResource({ uri });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.uri).toBe(uri);
return content;
}
// Helper to read resource and check for text/plain list content
async function checkTextListResponse(uri: string, expectedSubstrings: string[]): Promise<string> {
const content = await readResourceAndCheck(uri);
expect(content.mimeType).toBe('text/plain');
expect(content.isError).toBeFalsy();
if (!hasTextContent(content)) throw new Error('Expected text content');
for (const sub of expectedSubstrings) {
expect(content.text).toContain(sub);
}
return content.text;
}
// Helper to read resource and check for JSON detail content
async function checkJsonDetailResponse(uri: string, expectedObject: object): Promise<unknown> {
const content = await readResourceAndCheck(uri);
expect(content.mimeType).toBe('application/json');
expect(content.isError).toBeFalsy();
if (!hasTextContent(content)) throw new Error('Expected text content');
const data = parseJsonSafely(content.text);
expect(data).toMatchObject(expectedObject);
return data;
}
// Helper to read resource and check for error
async function checkErrorResponse(uri: string, expectedErrorText: string): Promise<void> {
const content = await readResourceAndCheck(uri);
expect(content.isError).toBe(true);
expect(content.mimeType).toBe('text/plain'); // Errors are plain text
if (!hasTextContent(content)) throw new Error('Expected text content for error');
expect(content.text).toContain(expectedErrorText);
}
describe('openapi://{field}', () => {
beforeEach(async () => await setup());
it('should retrieve the "info" field', async () => {
// Matches complex-endpoint.json
await checkJsonDetailResponse('openapi://info', {
title: 'Complex Endpoint Test API',
version: '1.0.0',
});
});
it('should retrieve the "paths" list', async () => {
// Matches complex-endpoint.json
await checkTextListResponse('openapi://paths', [
'Hint:',
'GET POST /api/v1/organizations/{orgId}/projects/{projectId}/tasks',
]);
});
it('should retrieve the "components" list', async () => {
// Matches complex-endpoint.json (only has schemas)
await checkTextListResponse('openapi://components', [
'Available Component Types:',
'- schemas',
"Hint: Use 'openapi://components/{type}'",
]);
});
it('should return error for invalid field', async () => {
const uri = 'openapi://invalidfield';
await checkErrorResponse(uri, 'Field "invalidfield" not found');
});
});
describe('openapi://paths/{path}', () => {
beforeEach(async () => await setup());
it('should list methods for the complex task path', async () => {
const complexPath = 'api/v1/organizations/{orgId}/projects/{projectId}/tasks';
const encodedPath = encodeURIComponent(complexPath);
// Update expected format based on METHOD: Summary/OpId
await checkTextListResponse(`openapi://paths/${encodedPath}`, [
"Hint: Use 'openapi://paths/api%2Fv1%2Forganizations%2F%7BorgId%7D%2Fprojects%2F%7BprojectId%7D%2Ftasks/{method}'", // Hint comes first now
'', // Blank line after hint
'GET: Get Tasks', // METHOD: summary
'POST: Create Task', // METHOD: summary
]);
});
it('should return error for non-existent path', async () => {
const encodedPath = encodeURIComponent('nonexistent');
const uri = `openapi://paths/${encodedPath}`;
// Updated error message from getValidatedPathItem
await checkErrorResponse(uri, 'Path "/nonexistent" not found in the specification.');
});
});
describe('openapi://paths/{path}/{method*}', () => {
beforeEach(async () => await setup());
it('should get details for GET on complex path', async () => {
const complexPath = 'api/v1/organizations/{orgId}/projects/{projectId}/tasks';
const encodedPath = encodeURIComponent(complexPath);
// Check operationId from complex-endpoint.json
await checkJsonDetailResponse(`openapi://paths/${encodedPath}/get`, {
operationId: 'getProjectTasks',
});
});
it('should get details for multiple methods GET,POST on complex path', async () => {
const complexPath = 'api/v1/organizations/{orgId}/projects/{projectId}/tasks';
const encodedPath = encodeURIComponent(complexPath);
const result = await client.readResource({ uri: `openapi://paths/${encodedPath}/get,post` });
expect(result.contents).toHaveLength(2);
const getContent = result.contents.find(c => c.uri.endsWith('/get'));
expect(getContent).toBeDefined();
expect(getContent?.isError).toBeFalsy();
if (!getContent || !hasTextContent(getContent))
throw new Error('Expected text content for GET');
const getData = parseJsonSafely(getContent.text);
// Check operationId from complex-endpoint.json
expect(getData).toMatchObject({ operationId: 'getProjectTasks' });
const postContent = result.contents.find(c => c.uri.endsWith('/post'));
expect(postContent).toBeDefined();
expect(postContent?.isError).toBeFalsy();
if (!postContent || !hasTextContent(postContent))
throw new Error('Expected text content for POST');
const postData = parseJsonSafely(postContent.text);
// Check operationId from complex-endpoint.json
expect(postData).toMatchObject({ operationId: 'createProjectTask' });
});
it('should return error for invalid method on complex path', async () => {
const complexPath = 'api/v1/organizations/{orgId}/projects/{projectId}/tasks';
const encodedPath = encodeURIComponent(complexPath);
const uri = `openapi://paths/${encodedPath}/put`;
// Updated error message from getValidatedOperations
await checkErrorResponse(
uri,
'None of the requested methods (put) are valid for path "/api/v1/organizations/{orgId}/projects/{projectId}/tasks". Available methods: get, post'
);
});
});
describe('openapi://components/{type}', () => {
beforeEach(async () => await setup());
it('should list schemas', async () => {
// Matches complex-endpoint.json
await checkTextListResponse('openapi://components/schemas', [
'Available schemas:',
'- CreateTaskRequest',
'- Task',
'- TaskList',
"Hint: Use 'openapi://components/schemas/{name}'",
]);
});
it('should return error for invalid type', async () => {
const uri = 'openapi://components/invalid';
await checkErrorResponse(uri, 'Invalid component type: invalid');
});
});
describe('openapi://components/{type}/{name*}', () => {
beforeEach(async () => await setup());
it('should get details for schema Task', async () => {
// Matches complex-endpoint.json
await checkJsonDetailResponse('openapi://components/schemas/Task', {
type: 'object',
properties: { id: { type: 'string' }, title: { type: 'string' } },
});
});
it('should get details for multiple schemas Task,TaskList', async () => {
// Matches complex-endpoint.json
const result = await client.readResource({
uri: 'openapi://components/schemas/Task,TaskList',
});
expect(result.contents).toHaveLength(2);
const taskContent = result.contents.find(c => c.uri.endsWith('/Task'));
expect(taskContent).toBeDefined();
expect(taskContent?.isError).toBeFalsy();
if (!taskContent || !hasTextContent(taskContent))
throw new Error('Expected text content for Task');
const taskData = parseJsonSafely(taskContent.text);
expect(taskData).toMatchObject({ properties: { id: { type: 'string' } } });
const taskListContent = result.contents.find(c => c.uri.endsWith('/TaskList'));
expect(taskListContent).toBeDefined();
expect(taskListContent?.isError).toBeFalsy();
if (!taskListContent || !hasTextContent(taskListContent))
throw new Error('Expected text content for TaskList');
const taskListData = parseJsonSafely(taskListContent.text);
expect(taskListData).toMatchObject({ properties: { items: { type: 'array' } } });
});
it('should return error for invalid name', async () => {
const uri = 'openapi://components/schemas/InvalidSchemaName';
// Updated error message from getValidatedComponentDetails with sorted names
await checkErrorResponse(
uri,
'None of the requested names (InvalidSchemaName) are valid for component type "schemas". Available names: CreateTaskRequest, Task, TaskList'
);
});
});
// Removed ListResourceTemplates test suite as the 'complete' property
// is likely not part of the standard response payload.
// We assume the templates are registered correctly in src/index.ts.
describe('Completion Tests', () => {
beforeEach(async () => await setup()); // Use the same setup
it('should provide completions for {field}', async () => {
const params = {
argument: { name: 'field', value: '' }, // Empty value to get all
ref: { type: 'ref/resource' as const, uri: 'openapi://{field}' },
};
const result = await client.complete(params);
expect(result.completion).toBeDefined();
expect(result.completion.values).toEqual(
expect.arrayContaining(['openapi', 'info', 'paths', 'components']) // Based on complex-endpoint.json
);
expect(result.completion.values).toHaveLength(4);
});
it('should provide completions for {path}', async () => {
const params = {
argument: { name: 'path', value: '' }, // Empty value to get all
ref: { type: 'ref/resource' as const, uri: 'openapi://paths/{path}' },
};
const result = await client.complete(params);
expect(result.completion).toBeDefined();
// Check for the encoded path from complex-endpoint.json
expect(result.completion.values).toEqual([
'api%2Fv1%2Forganizations%2F%7BorgId%7D%2Fprojects%2F%7BprojectId%7D%2Ftasks',
]);
});
it('should provide completions for {method*}', async () => {
const params = {
argument: { name: 'method', value: '' }, // Empty value to get all
ref: {
type: 'ref/resource' as const,
uri: 'openapi://paths/{path}/{method*}', // Use the exact template URI
},
};
const result = await client.complete(params);
expect(result.completion).toBeDefined();
// Check for the static list of methods defined in src/index.ts
expect(result.completion.values).toEqual([
'GET',
'POST',
'PUT',
'DELETE',
'PATCH',
'OPTIONS',
'HEAD',
'TRACE',
]);
});
it('should provide completions for {type}', async () => {
const params = {
argument: { name: 'type', value: '' }, // Empty value to get all
ref: { type: 'ref/resource' as const, uri: 'openapi://components/{type}' },
};
const result = await client.complete(params);
expect(result.completion).toBeDefined();
// Check for component types in complex-endpoint.json
expect(result.completion.values).toEqual(['schemas']);
});
// Updated test for conditional name completion
it('should provide completions for {name*} when only one component type exists', async () => {
// complex-endpoint.json only has 'schemas'
const params = {
argument: { name: 'name', value: '' },
ref: {
type: 'ref/resource' as const,
uri: 'openapi://components/{type}/{name*}', // Use the exact template URI
},
};
const result = await client.complete(params);
expect(result.completion).toBeDefined();
// Expect schema names from complex-endpoint.json
expect(result.completion.values).toEqual(
expect.arrayContaining(['CreateTaskRequest', 'Task', 'TaskList'])
);
expect(result.completion.values).toHaveLength(3);
});
// New test for multiple component types
it('should NOT provide completions for {name*} when multiple component types exist', async () => {
// Need to restart the server with the multi-component spec
await testContext?.cleanup(); // Clean up previous server
const multiSpecPath = path.resolve(__dirname, '../../fixtures/multi-component-types.json');
await setup(multiSpecPath); // Restart server with new spec
const params = {
argument: { name: 'name', value: '' },
ref: {
type: 'ref/resource' as const,
uri: 'openapi://components/{type}/{name*}', // Use the exact template URI
},
};
const result = await client.complete(params);
expect(result.completion).toBeDefined();
// Expect empty array because multiple types (schemas, parameters) exist
expect(result.completion.values).toEqual([]);
});
});
});
```