From 70fada22d6954f2c797f7521c3a20e2505f6bc08 Mon Sep 17 00:00:00 2001 From: rimskij Date: Mon, 12 Jan 2026 17:45:18 +0100 Subject: [PATCH] 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 --- src/lib/cache.ts | 8 ++++- src/lib/parser.ts | 41 +++++++++-------------- src/lib/schema-utils.ts | 45 ++++++++++++++++++++++++++ src/lib/spec-guards.ts | 70 ++++++++++++++++++++++++++++++++++++++++ src/lib/tool-response.ts | 33 +++++++++++++++++++ src/lib/types.ts | 6 ++++ src/lib/validator.ts | 3 +- src/tools/generate.ts | 47 ++++++--------------------- src/tools/parse.ts | 27 +++------------- src/tools/query.ts | 28 ++++------------ src/tools/schema.ts | 59 ++++----------------------------- src/tools/validate.ts | 25 +++----------- 12 files changed, 212 insertions(+), 180 deletions(-) create mode 100644 src/lib/schema-utils.ts create mode 100644 src/lib/spec-guards.ts create mode 100644 src/lib/tool-response.ts diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 67a163b..af95d24 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -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; diff --git a/src/lib/parser.ts b/src/lib/parser.ts index f75cd88..475d972 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -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 { } 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 []; diff --git a/src/lib/schema-utils.ts b/src/lib/schema-utils.ts new file mode 100644 index 0000000..f2d976a --- /dev/null +++ b/src/lib/schema-utils.ts @@ -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 { + // OpenAPI 3.x: components.schemas + const spec3 = spec as { components?: { schemas?: Record } }; + if (spec3.components?.schemas) { + return spec3.components.schemas; + } + + // Swagger 2.0: definitions + const spec2 = spec as { definitions?: Record }; + 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; +} diff --git a/src/lib/spec-guards.ts b/src/lib/spec-guards.ts new file mode 100644 index 0000000..1ac1974 --- /dev/null +++ b/src/lib/spec-guards.ts @@ -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 { + if (isOpenAPIV3(spec)) { + return spec.components?.schemas as Record ?? {}; + } + if (isSwaggerV2(spec)) { + return spec.definitions ?? {}; + } + return {}; +} + +/** + * Get paths object from spec + */ +export function getPathsTyped(spec: OpenAPI.Document): Record { + return (spec as { paths?: Record }).paths ?? {}; +} diff --git a/src/lib/tool-response.ts b/src/lib/tool-response.ts new file mode 100644 index 0000000..4ff959e --- /dev/null +++ b/src/lib/tool-response.ts @@ -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; +} + +/** + * Create a success response with text content and structured data. + */ +export function successResponse(text: string, data: Record): 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 }, + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index ac8f306..3fae64f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; diff --git a/src/lib/validator.ts b/src/lib/validator.ts index 931e11c..f0edfed 100644 --- a/src/lib/validator.ts +++ b/src/lib/validator.ts @@ -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 { @@ -54,7 +55,7 @@ export async function validateWithWarnings(specPath: string): Promise)[method] as { operationId?: string } | undefined; if (operation && !operation.operationId) { result.warnings.push({ diff --git a/src/tools/generate.ts b/src/tools/generate.ts index 0e5e65b..c9cbdaf 100644 --- a/src/tools/generate.ts +++ b/src/tools/generate.ts @@ -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; -}> { +}): Promise { 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 { - // OpenAPI 3.x - const spec3 = spec as { components?: { schemas?: Record } }; - if (spec3.components?.schemas) { - return spec3.components.schemas; - } - - // Swagger 2.0 - const spec2 = spec as { definitions?: Record }; - if (spec2.definitions) { - return spec2.definitions; - } - - return {}; -} - /** Get indentation string based on options */ function getIndent(options?: TypeScriptOptions): string { const style = options?.indentation ?? '2'; diff --git a/src/tools/parse.ts b/src/tools/parse.ts index 2c59b11..94f2e25 100644 --- a/src/tools/parse.ts +++ b/src/tools/parse.ts @@ -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; -}> { +}): Promise { 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'); } } diff --git a/src/tools/query.ts b/src/tools/query.ts index 914ce86..2857bf8 100644 --- a/src/tools/query.ts +++ b/src/tools/query.ts @@ -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; -}> { +}): Promise { 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'); } } diff --git a/src/tools/schema.ts b/src/tools/schema.ts index a5f64da..7235f88 100644 --- a/src/tools/schema.ts +++ b/src/tools/schema.ts @@ -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; -}> { +}): Promise { 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 } }; - if (spec3.components?.schemas?.[schemaName]) { - return spec3.components.schemas[schemaName]; - } - - // Swagger 2.0: definitions - const spec2 = spec as { definitions?: Record }; - 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 } }; - if (spec3.components?.schemas) { - return Object.keys(spec3.components.schemas); - } - - // Swagger 2.0 - const spec2 = spec as { definitions?: Record }; - if (spec2.definitions) { - return Object.keys(spec2.definitions); - } - - return []; -} diff --git a/src/tools/validate.ts b/src/tools/validate.ts index d4d187f..5605efc 100644 --- a/src/tools/validate.ts +++ b/src/tools/validate.ts @@ -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; -}> { +export async function validateToolHandler({ path }: { path: string }): Promise { 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'); } }