import type { OpenAPIV3 } from "openapi-types";
import { store, truncateSchema, handleCircularRef } from "./openapi-store.js";
import type {
LoadInput,
LoadResult,
ListEndpointsInput,
ListEndpointsResult,
EndpointSummary,
GetEndpointInput,
EndpointDetail,
ParameterDetail,
RequestBodyDetail,
ResponseDetail,
MediaTypeContent,
ErrorResponse,
OpenAPIDocument,
} from "./types.js";
const HTTP_METHODS = ["get", "post", "put", "delete", "patch", "options", "head", "trace"] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
/**
* Tool: openapi_load
* Load an OpenAPI spec from URL or file path
*/
export async function openapiLoad(input: LoadInput): Promise<LoadResult | ErrorResponse> {
// Validate input
if (!input.source || typeof input.source !== "string") {
return {
error: true,
code: "VALIDATION_ERROR",
message: "source is required and must be a string",
};
}
if (!input.alias || typeof input.alias !== "string") {
return {
error: true,
code: "VALIDATION_ERROR",
message: "alias is required and must be a string",
};
}
return store.load(input.alias, input.source);
}
/**
* Tool: openapi_list_endpoints
* List all endpoints from a loaded spec with optional filtering
*/
export function openapiListEndpoints(
input: ListEndpointsInput
): ListEndpointsResult | ErrorResponse {
// Validate input
if (!input.alias || typeof input.alias !== "string") {
return {
error: true,
code: "VALIDATION_ERROR",
message: "alias is required and must be a string",
};
}
const doc = store.get(input.alias);
if (!doc) {
const available = store.list();
return {
error: true,
code: "SPEC_NOT_FOUND",
message: `No spec loaded with alias '${input.alias}'. Available: ${available.length > 0 ? available.join(", ") : "none"}`,
};
}
const endpoints: EndpointSummary[] = [];
if (doc.paths) {
for (const [path, pathItem] of Object.entries(doc.paths)) {
if (!pathItem) continue;
for (const method of HTTP_METHODS) {
const operation = pathItem[method] as OpenAPIV3.OperationObject | undefined;
if (!operation) continue;
const endpoint: EndpointSummary = {
method: method.toUpperCase(),
path,
operationId: operation.operationId ?? null,
summary: operation.summary ?? null,
tags: operation.tags ?? [],
deprecated: operation.deprecated ?? false,
};
// Apply tag filter
if (input.tag) {
if (!endpoint.tags.includes(input.tag)) {
continue;
}
}
// Apply search filter (case-insensitive)
if (input.search) {
const searchLower = input.search.toLowerCase();
const pathMatch = path.toLowerCase().includes(searchLower);
const summaryMatch = endpoint.summary?.toLowerCase().includes(searchLower) ?? false;
const operationIdMatch =
endpoint.operationId?.toLowerCase().includes(searchLower) ?? false;
if (!pathMatch && !summaryMatch && !operationIdMatch) {
continue;
}
}
endpoints.push(endpoint);
}
}
}
// Sort by path, then by method
endpoints.sort((a, b) => {
const pathCompare = a.path.localeCompare(b.path);
if (pathCompare !== 0) return pathCompare;
return a.method.localeCompare(b.method);
});
return {
alias: input.alias,
total: endpoints.length,
endpoints,
};
}
/**
* Tool: openapi_get_endpoint
* Get complete details about a specific endpoint
*/
export function openapiGetEndpoint(input: GetEndpointInput): EndpointDetail | ErrorResponse {
// Validate input
if (!input.alias || typeof input.alias !== "string") {
return {
error: true,
code: "VALIDATION_ERROR",
message: "alias is required and must be a string",
};
}
if (!input.method || typeof input.method !== "string") {
return {
error: true,
code: "VALIDATION_ERROR",
message: "method is required and must be a string",
};
}
if (!input.path || typeof input.path !== "string") {
return {
error: true,
code: "VALIDATION_ERROR",
message: "path is required and must be a string",
};
}
const doc = store.get(input.alias);
if (!doc) {
const available = store.list();
return {
error: true,
code: "SPEC_NOT_FOUND",
message: `No spec loaded with alias '${input.alias}'. Available: ${available.length > 0 ? available.join(", ") : "none"}`,
};
}
const pathItem = doc.paths?.[input.path];
if (!pathItem) {
const hint = findSimilarPaths(doc, input.path);
return {
error: true,
code: "ENDPOINT_NOT_FOUND",
message: `Endpoint not found: ${input.method.toUpperCase()} ${input.path}${hint ? `. Did you mean: ${hint}` : ""}`,
};
}
const methodLower = input.method.toLowerCase() as HttpMethod;
const operation = pathItem[methodLower] as OpenAPIV3.OperationObject | undefined;
if (!operation) {
const availableMethods = HTTP_METHODS.filter((m) => m in pathItem).map((m) => m.toUpperCase());
return {
error: true,
code: "ENDPOINT_NOT_FOUND",
message: `Method ${input.method.toUpperCase()} not found for path ${input.path}. Available methods: ${availableMethods.join(", ")}`,
};
}
// Build parameters list (merge path-level and operation-level)
const parameters = buildParameters(pathItem, operation);
// Build request body
const requestBody = buildRequestBody(operation);
// Build responses
const responses = buildResponses(operation);
return {
method: input.method.toUpperCase(),
path: input.path,
operationId: operation.operationId ?? null,
summary: operation.summary ?? null,
description: operation.description ?? null,
tags: operation.tags ?? [],
deprecated: operation.deprecated ?? false,
parameters,
requestBody,
responses,
};
}
/**
* Find similar paths for helpful error messages
*/
function findSimilarPaths(doc: OpenAPIDocument, targetPath: string): string | null {
if (!doc.paths) return null;
const paths = Object.keys(doc.paths);
const targetParts = targetPath.split("/").filter(Boolean);
// Find paths that share the most segments
let bestMatch: string | null = null;
let bestScore = 0;
for (const path of paths) {
const parts = path.split("/").filter(Boolean);
let score = 0;
for (let i = 0; i < Math.min(targetParts.length, parts.length); i++) {
if (
targetParts[i] === parts[i] ||
parts[i].startsWith("{") ||
targetParts[i].startsWith("{")
) {
score++;
}
}
if (score > bestScore) {
bestScore = score;
bestMatch = path;
}
}
return bestScore > 0 ? bestMatch : null;
}
/**
* Build parameters from path item and operation
*/
function buildParameters(
pathItem: OpenAPIV3.PathItemObject,
operation: OpenAPIV3.OperationObject
): ParameterDetail[] {
const paramsMap = new Map<string, ParameterDetail>();
// Process path-level parameters first
const pathParams = (pathItem.parameters ?? []) as OpenAPIV3.ParameterObject[];
for (const param of pathParams) {
paramsMap.set(`${param.in}:${param.name}`, convertParameter(param));
}
// Operation-level parameters override path-level
const opParams = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[];
for (const param of opParams) {
paramsMap.set(`${param.in}:${param.name}`, convertParameter(param));
}
return Array.from(paramsMap.values());
}
/**
* Convert OpenAPI parameter to our format
*/
function convertParameter(param: OpenAPIV3.ParameterObject): ParameterDetail {
const schema = param.schema
? processSchema(param.schema as Record<string, unknown>)
: {};
return {
name: param.name,
in: param.in as "path" | "query" | "header" | "cookie",
required: param.required ?? false,
description: param.description ?? null,
schema,
example: param.example ?? null,
};
}
/**
* Build request body from operation
*/
function buildRequestBody(operation: OpenAPIV3.OperationObject): RequestBodyDetail | null {
const reqBody = operation.requestBody as OpenAPIV3.RequestBodyObject | undefined;
if (!reqBody) return null;
const content: Record<string, MediaTypeContent> = {};
if (reqBody.content) {
for (const [mediaType, mediaTypeObj] of Object.entries(reqBody.content)) {
content[mediaType] = {
schema: processSchema((mediaTypeObj.schema ?? {}) as Record<string, unknown>),
example: mediaTypeObj.example ?? null,
};
}
}
return {
required: reqBody.required ?? false,
description: reqBody.description ?? null,
content,
};
}
/**
* Build responses from operation
*/
function buildResponses(operation: OpenAPIV3.OperationObject): Record<string, ResponseDetail> {
const responses: Record<string, ResponseDetail> = {};
if (operation.responses) {
for (const [statusCode, responseObj] of Object.entries(operation.responses)) {
const response = responseObj as OpenAPIV3.ResponseObject;
let content: Record<string, MediaTypeContent> | null = null;
if (response.content) {
content = {};
for (const [mediaType, mediaTypeObj] of Object.entries(response.content)) {
content[mediaType] = {
schema: processSchema((mediaTypeObj.schema ?? {}) as Record<string, unknown>),
example: mediaTypeObj.example ?? null,
};
}
}
responses[statusCode] = {
description: response.description,
content,
};
}
}
return responses;
}
/**
* Process schema: handle circular refs and truncate if needed
*/
function processSchema(schema: Record<string, unknown>): Record<string, unknown> {
// Handle circular references
const seen = new WeakSet<object>();
const processed = handleCircularRef(seen, schema) as Record<string, unknown>;
// Truncate if too large
return truncateSchema(processed);
}
/**
* Tool: openapi_list_specs
* List all currently loaded OpenAPI specs
*/
export function openapiListSpecs(): { specs: Array<{ alias: string; title: string; version: string; endpointCount: number }> } {
const aliases = store.list();
const specs = aliases.map((alias) => {
const doc = store.get(alias)!;
let endpointCount = 0;
const methods = ["get", "post", "put", "delete", "patch", "options", "head", "trace"];
if (doc.paths) {
for (const pathItem of Object.values(doc.paths)) {
if (!pathItem) continue;
for (const method of methods) {
if (method in pathItem) endpointCount++;
}
}
}
return {
alias,
title: doc.info.title,
version: doc.info.version,
endpointCount,
};
});
return { specs };
}
/**
* Get all tool definitions for MCP server registration
*/
export function getToolDefinitions() {
const loadedSpecs = store.list();
const specsHint = loadedSpecs.length > 0
? ` Currently loaded: ${loadedSpecs.join(", ")}.`
: "";
return [
{
name: "openapi_list_specs",
description: "List all currently loaded OpenAPI specs with their aliases. Use this first to see what specs are available.",
inputSchema: {
type: "object" as const,
properties: {},
required: [],
},
},
{
name: "openapi_load",
description:
"Load an OpenAPI specification from a URL or local file path. Only needed if the spec is not already pre-loaded." + specsHint,
inputSchema: {
type: "object" as const,
properties: {
source: {
type: "string",
description: "URL or local file path to OpenAPI spec (JSON or YAML)",
},
alias: {
type: "string",
description: "Short name to reference this spec (e.g., 'stripe', 'github')",
},
},
required: ["source", "alias"],
},
},
{
name: "openapi_list_endpoints",
description: "List all endpoints from a loaded spec with optional filtering by tag or search." + specsHint,
inputSchema: {
type: "object" as const,
properties: {
alias: {
type: "string",
description: "Alias of the loaded spec to query" + (loadedSpecs.length > 0 ? ` (available: ${loadedSpecs.join(", ")})` : ""),
},
tag: {
type: "string",
description: "Optional: filter endpoints by tag",
},
search: {
type: "string",
description:
"Optional: filter by path, summary, or operationId (case-insensitive substring match)",
},
},
required: ["alias"],
},
},
{
name: "openapi_get_endpoint",
description: "Get complete details about a specific endpoint including parameters, request body, and responses." + specsHint,
inputSchema: {
type: "object" as const,
properties: {
alias: {
type: "string",
description: "Alias of the loaded spec" + (loadedSpecs.length > 0 ? ` (available: ${loadedSpecs.join(", ")})` : ""),
},
method: {
type: "string",
description: "HTTP method (GET, POST, etc.)",
},
path: {
type: "string",
description: "Endpoint path (e.g., '/pets/{petId}')",
},
},
required: ["alias", "method", "path"],
},
},
];
}