refactor: consolidate shared utilities and reduce code duplication

- Extract HTTP_METHODS constant to types.ts (eliminates duplication in 3 files)
- Add DEFAULT_CACHE_MAX_SIZE and DEFAULT_CACHE_TTL_MINUTES constants to cache.ts
- Create schema-utils.ts with getSchemas, findSchema, getSchemaNames, getSchemaCount
- Create spec-guards.ts with isOpenAPIV3, isSwaggerV2, getSpecVersion type guards
- Create tool-response.ts with successResponse, errorResponse helpers
- Update all tool handlers to use response helpers (~50 lines reduced)
- Update parser.ts to use type guards for version detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rimskij 2026-01-12 17:45:18 +01:00
parent cae5f7fce1
commit 70fada22d6
12 changed files with 212 additions and 180 deletions

View file

@ -2,6 +2,12 @@ import * as fs from 'fs';
import * as path from 'path';
import type { OpenAPISpec, ParsedSpec } from './types.js';
/** Default maximum number of cached specs */
export const DEFAULT_CACHE_MAX_SIZE = 10;
/** Default cache TTL in minutes */
export const DEFAULT_CACHE_TTL_MINUTES = 15;
export interface CacheEntry {
spec: OpenAPISpec;
metadata: ParsedSpec;
@ -14,7 +20,7 @@ export class SpecCache {
private maxSize: number;
private ttlMs: number;
constructor(maxSize = 10, ttlMinutes = 15) {
constructor(maxSize = DEFAULT_CACHE_MAX_SIZE, ttlMinutes = DEFAULT_CACHE_TTL_MINUTES) {
this.cache = new Map();
this.maxSize = maxSize;
this.ttlMs = ttlMinutes * 60 * 1000;

View file

@ -1,9 +1,10 @@
import SwaggerParser from '@apidevtools/swagger-parser';
import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import type { OpenAPIV3 } from 'openapi-types';
import { HTTP_METHODS } from './types.js';
import type { ParsedSpec, OpenAPISpec } from './types.js';
import { specCache, getCacheKey } from './cache.js';
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const;
import { getSchemaCount } from './schema-utils.js';
import { isOpenAPIV3, isSwaggerV2, getSpecVersion as getVersion } from './spec-guards.js';
export interface ParseOptions {
dereference?: boolean;
@ -66,7 +67,7 @@ export async function bundleSpec(specPath: string): Promise<OpenAPISpec> {
}
function extractMetadata(spec: OpenAPISpec): ParsedSpec {
const version = getSpecVersion(spec);
const version = getSpecVersionString(spec);
const info = spec.info;
const paths = spec.paths || {};
@ -103,35 +104,25 @@ function extractMetadata(spec: OpenAPISpec): ParsedSpec {
};
}
function getSpecVersion(spec: OpenAPISpec): string {
if ('openapi' in spec) {
return `OpenAPI ${spec.openapi}`;
function getSpecVersionString(spec: OpenAPISpec): string {
const version = getVersion(spec);
if (isOpenAPIV3(spec)) {
return `OpenAPI ${version}`;
}
if ('swagger' in spec) {
return `Swagger ${spec.swagger}`;
if (isSwaggerV2(spec)) {
return `Swagger ${version}`;
}
return 'Unknown';
}
function getSchemaCount(spec: OpenAPISpec): number {
if ('components' in spec && spec.components?.schemas) {
return Object.keys(spec.components.schemas).length;
}
if ('definitions' in spec && spec.definitions) {
return Object.keys(spec.definitions).length;
}
return 0;
}
function getServers(spec: OpenAPISpec): string[] {
if ('servers' in spec && spec.servers) {
if (isOpenAPIV3(spec) && spec.servers) {
return spec.servers.map(s => s.url);
}
if ('host' in spec) {
const swagger2 = spec as { host?: string; basePath?: string; schemes?: string[] };
const scheme = swagger2.schemes?.[0] || 'https';
const host = swagger2.host || 'localhost';
const basePath = swagger2.basePath || '';
if (isSwaggerV2(spec)) {
const scheme = spec.schemes?.[0] || 'https';
const host = spec.host || 'localhost';
const basePath = spec.basePath || '';
return [`${scheme}://${host}${basePath}`];
}
return [];

45
src/lib/schema-utils.ts Normal file
View file

@ -0,0 +1,45 @@
/**
* Consolidated schema access utilities for OpenAPI/Swagger specs.
* Handles both OpenAPI 3.x (components.schemas) and Swagger 2.0 (definitions).
*/
/**
* Get all schemas from a spec, handling both OpenAPI 3.x and Swagger 2.0 formats.
*/
export function getSchemas(spec: object): Record<string, object> {
// OpenAPI 3.x: components.schemas
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
if (spec3.components?.schemas) {
return spec3.components.schemas;
}
// Swagger 2.0: definitions
const spec2 = spec as { definitions?: Record<string, object> };
if (spec2.definitions) {
return spec2.definitions;
}
return {};
}
/**
* Find a specific schema by name.
*/
export function findSchema(spec: object, schemaName: string): object | null {
const schemas = getSchemas(spec);
return schemas[schemaName] || null;
}
/**
* Get list of all schema names.
*/
export function getSchemaNames(spec: object): string[] {
return Object.keys(getSchemas(spec));
}
/**
* Get count of schemas in the spec.
*/
export function getSchemaCount(spec: object): number {
return Object.keys(getSchemas(spec)).length;
}

70
src/lib/spec-guards.ts Normal file
View file

@ -0,0 +1,70 @@
/**
* Type guards for OpenAPI specification version detection.
* Provides type-safe access to spec properties based on version.
*/
import type { OpenAPI, OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
/** OpenAPI 3.x specification (v3.0 or v3.1) */
export type OpenAPIV3Spec = OpenAPIV3.Document | OpenAPIV3_1.Document;
/** Swagger 2.0 specification */
export type SwaggerV2Spec = OpenAPIV2.Document;
/**
* Check if spec is OpenAPI 3.x (has 'openapi' property starting with '3.')
*/
export function isOpenAPIV3(spec: OpenAPI.Document): spec is OpenAPIV3Spec {
const openapi = (spec as { openapi?: string }).openapi;
return typeof openapi === 'string' && openapi.startsWith('3.');
}
/**
* Check if spec is Swagger 2.0 (has 'swagger' property equal to '2.0')
*/
export function isSwaggerV2(spec: OpenAPI.Document): spec is SwaggerV2Spec {
const swagger = (spec as { swagger?: string }).swagger;
return swagger === '2.0';
}
/**
* Check if spec is OpenAPI 3.1 specifically (has 'openapi' starting with '3.1')
*/
export function isOpenAPIV31(spec: OpenAPI.Document): spec is OpenAPIV3_1.Document {
const openapi = (spec as { openapi?: string }).openapi;
return typeof openapi === 'string' && openapi.startsWith('3.1');
}
/**
* Get the spec version string (e.g., "3.0.0", "3.1.0", "2.0")
*/
export function getSpecVersion(spec: OpenAPI.Document): string {
if (isOpenAPIV3(spec)) {
return (spec as { openapi: string }).openapi;
}
if (isSwaggerV2(spec)) {
return (spec as { swagger: string }).swagger;
}
return 'unknown';
}
/**
* Get schemas from spec with proper type narrowing
* Returns components.schemas for v3, definitions for v2
*/
export function getSchemasTyped(spec: OpenAPI.Document): Record<string, object> {
if (isOpenAPIV3(spec)) {
return spec.components?.schemas as Record<string, object> ?? {};
}
if (isSwaggerV2(spec)) {
return spec.definitions ?? {};
}
return {};
}
/**
* Get paths object from spec
*/
export function getPathsTyped(spec: OpenAPI.Document): Record<string, object> {
return (spec as { paths?: Record<string, object> }).paths ?? {};
}

33
src/lib/tool-response.ts Normal file
View file

@ -0,0 +1,33 @@
/**
* Tool response helpers for consistent MCP tool responses.
*/
/** Standard MCP tool response structure - compatible with MCP SDK index signature */
export interface ToolResponse {
[x: string]: unknown;
content: Array<{ type: 'text'; text: string }>;
structuredContent: Record<string, unknown>;
}
/**
* Create a success response with text content and structured data.
*/
export function successResponse(text: string, data: Record<string, unknown>): ToolResponse {
return {
content: [{ type: 'text', text }],
structuredContent: { success: true, ...data },
};
}
/**
* Create an error response with error message.
* @param message - Error message
* @param context - Optional context prefix (e.g., "parsing spec", "querying endpoints")
*/
export function errorResponse(message: string, context?: string): ToolResponse {
const text = context ? `Error ${context}: ${message}` : `Error: ${message}`;
return {
content: [{ type: 'text', text }],
structuredContent: { success: false, error: message },
};
}

View file

@ -3,6 +3,12 @@ import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
export type OpenAPISpec = OpenAPI.Document;
export type OpenAPIV3Spec = OpenAPIV3.Document | OpenAPIV3_1.Document;
/** Standard HTTP methods supported in OpenAPI specifications */
export const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const;
/** Type derived from HTTP_METHODS constant */
export type HttpMethod = typeof HTTP_METHODS[number];
export interface ParsedSpec {
version: string;
title: string;

View file

@ -1,4 +1,5 @@
import SwaggerParser from '@apidevtools/swagger-parser';
import { HTTP_METHODS } from './types.js';
import type { ValidationResult, ValidationError } from './types.js';
export async function validateSpec(specPath: string): Promise<ValidationResult> {
@ -54,7 +55,7 @@ export async function validateWithWarnings(specPath: string): Promise<Validation
if (spec.paths) {
for (const [path, pathItem] of Object.entries(spec.paths)) {
if (!pathItem) continue;
for (const method of ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']) {
for (const method of HTTP_METHODS) {
const operation = (pathItem as Record<string, unknown>)[method] as { operationId?: string } | undefined;
if (operation && !operation.operationId) {
result.warnings.push({

View file

@ -1,6 +1,9 @@
import { z } from 'zod';
import { parseSpec } from '../lib/parser.js';
import { formatTypes } from '../utils/format.js';
import { getSchemas } from '../lib/schema-utils.js';
import { successResponse, errorResponse } from '../lib/tool-response.js';
import type { ToolResponse } from '../lib/tool-response.js';
import type { TypeScriptOptions } from '../lib/types.js';
export const generateToolName = 'generate-types';
@ -31,10 +34,7 @@ export async function generateToolHandler({ path, schemas, noCache, options }: {
schemas?: string[];
noCache?: boolean;
options?: TypeScriptOptions;
}): Promise<{
content: Array<{ type: 'text'; text: string }>;
structuredContent: Record<string, unknown>;
}> {
}): Promise<ToolResponse> {
try {
// Validate mutually exclusive options
if (options?.enumAsUnion && options?.enumAsEnum) {
@ -71,43 +71,16 @@ export async function generateToolHandler({ path, schemas, noCache, options }: {
const types = generateTypeScript(schemasToGenerate, options);
const text = formatTypes(types);
return {
content: [{ type: 'text', text }],
structuredContent: {
success: true,
types,
generatedCount: Object.keys(schemasToGenerate).length,
options: options || {},
},
};
return successResponse(text, {
types,
generatedCount: Object.keys(schemasToGenerate).length,
options: options || {},
});
} catch (err) {
const error = err as Error;
return {
content: [{ type: 'text', text: `Error generating types: ${error.message}` }],
structuredContent: {
success: false,
error: error.message,
},
};
return errorResponse((err as Error).message, 'generating types');
}
}
function getSchemas(spec: object): Record<string, object> {
// OpenAPI 3.x
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
if (spec3.components?.schemas) {
return spec3.components.schemas;
}
// Swagger 2.0
const spec2 = spec as { definitions?: Record<string, object> };
if (spec2.definitions) {
return spec2.definitions;
}
return {};
}
/** Get indentation string based on options */
function getIndent(options?: TypeScriptOptions): string {
const style = options?.indentation ?? '2';

View file

@ -1,6 +1,8 @@
import { z } from 'zod';
import { parseSpec } from '../lib/parser.js';
import { formatMetadata } from '../utils/format.js';
import { successResponse, errorResponse } from '../lib/tool-response.js';
import type { ToolResponse } from '../lib/tool-response.js';
export const parseToolName = 'parse-spec';
@ -14,10 +16,7 @@ export const parseToolSchema = {
export async function parseToolHandler({ path, noCache }: {
path: string;
noCache?: boolean;
}): Promise<{
content: Array<{ type: 'text'; text: string }>;
structuredContent: Record<string, unknown>;
}> {
}): Promise<ToolResponse> {
try {
const { spec, metadata, dereferenced, cached } = await parseSpec(path, { noCache });
@ -29,24 +28,8 @@ export async function parseToolHandler({ path, noCache }: {
text += '\n\n*Served from cache*';
}
return {
content: [{ type: 'text', text }],
structuredContent: {
success: true,
metadata,
dereferenced,
cached,
spec,
},
};
return successResponse(text, { metadata, dereferenced, cached, spec });
} catch (err) {
const error = err as Error;
return {
content: [{ type: 'text', text: `Error parsing spec: ${error.message}` }],
structuredContent: {
success: false,
error: error.message,
},
};
return errorResponse((err as Error).message, 'parsing spec');
}
}

View file

@ -1,6 +1,9 @@
import { z } from 'zod';
import { parseSpec } from '../lib/parser.js';
import { formatEndpoints } from '../utils/format.js';
import { HTTP_METHODS } from '../lib/types.js';
import { successResponse, errorResponse } from '../lib/tool-response.js';
import type { ToolResponse } from '../lib/tool-response.js';
import type { EndpointInfo, EndpointFilter, ParameterInfo, ResponseInfo } from '../lib/types.js';
import type { OpenAPIV3 } from 'openapi-types';
@ -17,8 +20,6 @@ export const queryToolSchema = {
noCache: z.boolean().optional().describe('Bypass cache and parse fresh'),
};
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const;
export async function queryToolHandler(args: {
path: string;
method?: string;
@ -26,10 +27,7 @@ export async function queryToolHandler(args: {
tag?: string;
operationId?: string;
noCache?: boolean;
}): Promise<{
content: Array<{ type: 'text'; text: string }>;
structuredContent: Record<string, unknown>;
}> {
}): Promise<ToolResponse> {
try {
const { spec } = await parseSpec(args.path, { noCache: args.noCache });
@ -43,23 +41,9 @@ export async function queryToolHandler(args: {
const endpoints = extractEndpoints(spec, filter);
const text = formatEndpoints(endpoints);
return {
content: [{ type: 'text', text }],
structuredContent: {
success: true,
count: endpoints.length,
endpoints,
},
};
return successResponse(text, { count: endpoints.length, endpoints });
} catch (err) {
const error = err as Error;
return {
content: [{ type: 'text', text: `Error querying endpoints: ${error.message}` }],
structuredContent: {
success: false,
error: error.message,
},
};
return errorResponse((err as Error).message, 'querying endpoints');
}
}

View file

@ -1,6 +1,9 @@
import { z } from 'zod';
import { parseSpec } from '../lib/parser.js';
import { formatSchema } from '../utils/format.js';
import { findSchema, getSchemaNames } from '../lib/schema-utils.js';
import { successResponse, errorResponse } from '../lib/tool-response.js';
import type { ToolResponse } from '../lib/tool-response.js';
import type { SchemaInfo } from '../lib/types.js';
export const schemaToolName = 'get-schema';
@ -17,17 +20,14 @@ export async function schemaToolHandler({ path, schemaName, noCache }: {
path: string;
schemaName: string;
noCache?: boolean;
}): Promise<{
content: Array<{ type: 'text'; text: string }>;
structuredContent: Record<string, unknown>;
}> {
}): Promise<ToolResponse> {
try {
const { spec } = await parseSpec(path, { noCache });
const schema = findSchema(spec, schemaName);
if (!schema) {
const available = getAvailableSchemas(spec);
const available = getSchemaNames(spec);
return {
content: [{ type: 'text', text: `Schema '${schemaName}' not found. Available schemas: ${available.join(', ')}` }],
structuredContent: {
@ -49,53 +49,8 @@ export async function schemaToolHandler({ path, schemaName, noCache }: {
const text = formatSchema(schemaInfo);
return {
content: [{ type: 'text', text }],
structuredContent: {
success: true,
schema: schemaInfo,
},
};
return successResponse(text, { schema: schemaInfo });
} catch (err) {
const error = err as Error;
return {
content: [{ type: 'text', text: `Error getting schema: ${error.message}` }],
structuredContent: {
success: false,
error: error.message,
},
};
return errorResponse((err as Error).message, 'getting schema');
}
}
function findSchema(spec: object, schemaName: string): object | null {
// OpenAPI 3.x: components.schemas
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
if (spec3.components?.schemas?.[schemaName]) {
return spec3.components.schemas[schemaName];
}
// Swagger 2.0: definitions
const spec2 = spec as { definitions?: Record<string, object> };
if (spec2.definitions?.[schemaName]) {
return spec2.definitions[schemaName];
}
return null;
}
function getAvailableSchemas(spec: object): string[] {
// OpenAPI 3.x
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
if (spec3.components?.schemas) {
return Object.keys(spec3.components.schemas);
}
// Swagger 2.0
const spec2 = spec as { definitions?: Record<string, object> };
if (spec2.definitions) {
return Object.keys(spec2.definitions);
}
return [];
}

View file

@ -1,6 +1,8 @@
import { z } from 'zod';
import { validateWithWarnings } from '../lib/validator.js';
import { formatValidation } from '../utils/format.js';
import { successResponse, errorResponse } from '../lib/tool-response.js';
import type { ToolResponse } from '../lib/tool-response.js';
export const validateToolName = 'validate-spec';
@ -10,29 +12,12 @@ export const validateToolSchema = {
path: z.string().describe('Path to the OpenAPI/Swagger spec file (YAML or JSON)'),
};
export async function validateToolHandler({ path }: { path: string }): Promise<{
content: Array<{ type: 'text'; text: string }>;
structuredContent: Record<string, unknown>;
}> {
export async function validateToolHandler({ path }: { path: string }): Promise<ToolResponse> {
try {
const result = await validateWithWarnings(path);
const text = formatValidation(result);
return {
content: [{ type: 'text', text }],
structuredContent: {
success: true,
...result,
},
};
return successResponse(text, { ...result });
} catch (err) {
const error = err as Error;
return {
content: [{ type: 'text', text: `Error validating spec: ${error.message}` }],
structuredContent: {
success: false,
error: error.message,
},
};
return errorResponse((err as Error).message, 'validating spec');
}
}