@nic0xflamel/lunarcrush-mcp-server
Version:
MCP server for the LunarCrush API (Enterprise)
470 lines • 21.3 kB
JavaScript
export class OpenAPIToMCPConverter {
constructor(openApiSpec) {
this.openApiSpec = openApiSpec;
this.schemaCache = {};
this.nameCounter = 0;
}
/**
* Resolve a $ref reference to its schema in the openApiSpec.
* Returns the raw OpenAPI SchemaObject or null if not found.
*/
internalResolveRef(ref, resolvedRefs) {
if (!ref.startsWith('#/')) {
return null;
}
if (resolvedRefs.has(ref)) {
return null;
}
const parts = ref.replace(/^#\//, '').split('/');
let current = this.openApiSpec;
for (const part of parts) {
current = current[part];
if (!current)
return null;
}
resolvedRefs.add(ref);
return current;
}
/**
* Convert an OpenAPI schema (or reference) into a JSON Schema object.
* Uses caching and handles cycles by returning $ref nodes.
*/
convertOpenApiSchemaToJsonSchema(schema, resolvedRefs, resolveRefs = false, name) {
if ('$ref' in schema) {
const ref = schema.$ref;
if (!resolveRefs) {
if (ref.startsWith('#/components/schemas/')) {
return {
$ref: ref.replace(/^#\/components\/schemas\//, '#/$defs/'),
...('description' in schema ? { description: schema.description } : {}),
};
}
// console.error(`Attempting to resolve ref ${ref} not found in components collection.`); // Commented out
// deliberate fall through
}
// Create base schema with $ref and description if present
const refSchema = { $ref: ref };
if ('description' in schema && schema.description) {
refSchema.description = schema.description;
}
// If already cached, return immediately with description
if (this.schemaCache[ref]) {
return this.schemaCache[ref];
}
const resolved = this.internalResolveRef(ref, resolvedRefs);
if (!resolved) {
// TODO: need extensive tests for this and we definitely need to handle the case of self references
// console.error(`Failed to resolve ref ${ref}`); // Commented out
return {
$ref: ref.replace(/^#\/components\/schemas\//, '#/$defs/'),
description: 'description' in schema ? (schema.description ?? '') : '',
};
}
else {
const converted = this.convertOpenApiSchemaToJsonSchema(resolved, resolvedRefs, resolveRefs, name);
this.schemaCache[ref] = converted;
return converted;
}
}
// Handle inline schema
const result = {};
const baseDescription = 'description' in schema ? schema.description : undefined;
const originalType = 'type' in schema ? schema.type : undefined;
// --- Handle potentially non-standard 'timestamp' type ---
// Use type assertion to check for 'timestamp' despite base types
if (originalType === 'timestamp') {
result.type = 'integer'; // Convert to integer for Unix timestamp
result.description = baseDescription ? `${baseDescription} (Unix timestamp)` : 'Unix timestamp';
}
else if (originalType) {
result.type = originalType; // Use the standard type
if (baseDescription) {
result.description = baseDescription;
}
}
else if (baseDescription) { // Handle case where only description might be present without type
result.description = baseDescription;
}
// -------------------------------------------------------
// Convert binary format to uri-reference and enhance description
if ('format' in schema && schema.format === 'binary') {
result.format = 'uri-reference';
const binaryDesc = 'absolute paths to local files';
// Append binary format info, don't overwrite if description already set (e.g., by timestamp logic)
result.description = result.description ? `${result.description} (${binaryDesc})` : binaryDesc;
}
else if ('format' in schema && schema.format) { // Only add format if not binary
result.format = schema.format;
}
if (schema.enum) {
result.enum = schema.enum;
}
if (schema.default !== undefined) {
result.default = schema.default;
}
// Handle object properties
if (schema.type === 'object') {
result.type = 'object';
if (schema.properties) {
result.properties = {};
for (const [name, propSchema] of Object.entries(schema.properties)) {
result.properties[name] = this.convertOpenApiSchemaToJsonSchema(propSchema, resolvedRefs, resolveRefs, name);
}
}
if (schema.required) {
result.required = schema.required;
}
if (schema.additionalProperties === true || schema.additionalProperties === undefined) {
result.additionalProperties = true;
}
else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
result.additionalProperties = this.convertOpenApiSchemaToJsonSchema(schema.additionalProperties, resolvedRefs, resolveRefs, name);
}
else {
result.additionalProperties = false;
}
}
// Handle arrays - ensure binary format conversion happens for array items too
if (schema.type === 'array' && schema.items) {
result.type = 'array';
result.items = this.convertOpenApiSchemaToJsonSchema(schema.items, resolvedRefs, resolveRefs, name);
}
// oneOf, anyOf, allOf
if (schema.oneOf) {
result.oneOf = schema.oneOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs, name));
}
if (schema.anyOf) {
result.anyOf = schema.anyOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs, name));
}
if (schema.allOf) {
result.allOf = schema.allOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs, name));
}
return result;
}
convertToMCPTools() {
const apiName = 'API';
// Define the list of allowed PATHS
const allowedPaths = new Set([
'/public/topic/:topic/v1',
'/public/topic/:topic/time-series/v2',
'/public/topic/:topic/posts/v1',
'/public/topic/:topic/news/v1',
'/public/coins/list/v2',
'/public/coins/:coin/v1',
'/public/coins/:coin/time-series/v2'
]);
const openApiLookup = {};
const tools = {
[apiName]: { methods: [] },
};
const zip = {};
for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
if (!pathItem)
continue;
// Filter based on path
if (!allowedPaths.has(path)) {
continue; // Skip this path if it's not in the allowed list
}
for (const [method, operation] of Object.entries(pathItem)) {
if (!this.isOperation(method, operation))
continue;
// Generate a name based on method and path if operationId is missing
const baseName = operation.operationId ?? this.generateNameFromPath(method, path);
const mcpMethod = this.convertOperationToMCPMethod(operation, method, path, baseName); // Pass baseName
if (mcpMethod) {
// Use the generated/provided baseName for lookup and tool structure
const uniqueName = this.ensureUniqueName(baseName);
mcpMethod.name = uniqueName; // Use the unique name for the MCP method
// Store using the unique name
tools[apiName].methods.push(mcpMethod);
openApiLookup[apiName + '-' + uniqueName] = { ...operation, method, path };
zip[apiName + '-' + uniqueName] = { openApi: { ...operation, method, path }, mcp: mcpMethod };
}
}
}
return { tools, openApiLookup, zip };
}
/**
* Convert the OpenAPI spec to OpenAI's ChatCompletionTool format
*/
convertToOpenAITools() {
const tools = [];
for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
if (!pathItem)
continue;
for (const [method, operation] of Object.entries(pathItem)) {
if (!this.isOperation(method, operation))
continue;
const parameters = this.convertOperationToJsonSchema(operation, method, path);
const tool = {
type: 'function',
function: {
name: operation.operationId,
description: operation.summary || operation.description || '',
parameters: parameters,
},
};
tools.push(tool);
}
}
return tools;
}
/**
* Convert the OpenAPI spec to Anthropic's Tool format
*/
convertToAnthropicTools() {
const tools = [];
for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
if (!pathItem)
continue;
for (const [method, operation] of Object.entries(pathItem)) {
if (!this.isOperation(method, operation))
continue;
const parameters = this.convertOperationToJsonSchema(operation, method, path);
const tool = {
name: operation.operationId,
description: operation.summary || operation.description || '',
input_schema: parameters,
};
tools.push(tool);
}
}
return tools;
}
convertComponentsToJsonSchema() {
const components = this.openApiSpec.components || {};
const schema = {};
for (const [key, value] of Object.entries(components.schemas || {})) {
schema[key] = this.convertOpenApiSchemaToJsonSchema(value, new Set());
}
return schema;
}
/**
* Helper method to convert an operation to a JSON Schema for parameters
*/
convertOperationToJsonSchema(operation, method, path) {
const schema = {
type: 'object',
properties: {},
required: [],
$defs: this.convertComponentsToJsonSchema(),
};
// Handle parameters (path, query, header, cookie)
if (operation.parameters) {
for (const param of operation.parameters) {
const paramObj = this.resolveParameter(param);
if (paramObj && paramObj.schema) {
const paramSchema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set(), false, paramObj.name);
// Merge parameter-level description if available
if (paramObj.description) {
paramSchema.description = paramObj.description;
}
schema.properties[paramObj.name] = paramSchema;
if (paramObj.required) {
schema.required.push(paramObj.name);
}
}
}
}
// Handle requestBody
if (operation.requestBody) {
const bodyObj = this.resolveRequestBody(operation.requestBody);
if (bodyObj?.content) {
if (bodyObj.content['application/json']?.schema) {
const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set(), false);
if (bodySchema.type === 'object' && bodySchema.properties) {
for (const [name, propSchema] of Object.entries(bodySchema.properties)) {
schema.properties[name] = propSchema;
}
if (bodySchema.required) {
schema.required.push(...bodySchema.required);
}
}
}
}
}
return schema;
}
isOperation(method, operation) {
return ['get', 'post', 'put', 'delete', 'patch'].includes(method.toLowerCase());
}
isParameterObject(param) {
return !('$ref' in param);
}
isRequestBodyObject(body) {
return !('$ref' in body);
}
resolveParameter(param) {
if (this.isParameterObject(param)) {
return param;
}
else {
const resolved = this.internalResolveRef(param.$ref, new Set());
if (resolved && resolved.name) {
return resolved;
}
}
return null;
}
resolveRequestBody(body) {
if (this.isRequestBodyObject(body)) {
return body;
}
else {
const resolved = this.internalResolveRef(body.$ref, new Set());
if (resolved) {
return resolved;
}
}
return null;
}
resolveResponse(response) {
if ('$ref' in response) {
const resolved = this.internalResolveRef(response.$ref, new Set());
if (resolved) {
return resolved;
}
else {
return null;
}
}
return response;
}
convertOperationToMCPMethod(operation, method, path, baseName) {
// Use the provided baseName instead of relying on operation.operationId directly
const methodName = baseName;
const inputSchema = {
$defs: this.convertComponentsToJsonSchema(),
type: 'object',
properties: {},
required: [],
};
// Handle parameters (path, query, header, cookie)
if (operation.parameters) {
for (const param of operation.parameters) {
const paramObj = this.resolveParameter(param);
if (paramObj && paramObj.schema) {
const schema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set(), false);
// --- Improve parameter description ---
let paramDescription = paramObj.description || '';
if (paramObj.name === 'topic') {
paramDescription = `The specific social topic to query (e.g., 'bitcoin', 'ethereum'). ${paramDescription}`;
}
else if (paramObj.name === 'coin') {
paramDescription = `The coin symbol or ID (e.g., 'BTC', 'ETH', or numeric ID from coins list). ${paramDescription}`;
}
schema.description = paramDescription.trim(); // Use updated description
// -------------------------------------
inputSchema.properties[paramObj.name] = schema;
if (paramObj.required) {
inputSchema.required.push(paramObj.name);
}
}
}
}
// Handle requestBody
if (operation.requestBody) {
const bodyObj = this.resolveRequestBody(operation.requestBody);
if (bodyObj?.content) {
// Handle multipart/form-data for file uploads
// We convert the multipart/form-data schema to a JSON schema and we require
// that the user passes in a string for each file that points to the local file
if (bodyObj.content['multipart/form-data']?.schema) {
const formSchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['multipart/form-data'].schema, new Set(), false);
if (formSchema.type === 'object' && formSchema.properties) {
for (const [name, propSchema] of Object.entries(formSchema.properties)) {
inputSchema.properties[name] = propSchema;
}
if (formSchema.required) {
inputSchema.required.push(...formSchema.required);
}
}
}
// Handle application/json
else if (bodyObj.content['application/json']?.schema) {
const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set(), false);
// Merge body schema into the inputSchema's properties
if (bodySchema.type === 'object' && bodySchema.properties) {
for (const [name, propSchema] of Object.entries(bodySchema.properties)) {
inputSchema.properties[name] = propSchema;
}
if (bodySchema.required) {
inputSchema.required.push(...bodySchema.required);
}
}
else {
// If the request body is not an object, just put it under "body"
inputSchema.properties['body'] = bodySchema;
inputSchema.required.push('body');
}
}
}
}
// Build description including error responses
let description = operation.summary || operation.description || '';
if (operation.responses) {
const errorResponses = Object.entries(operation.responses)
.filter(([code]) => code.startsWith('4') || code.startsWith('5'))
.map(([code, response]) => {
const responseObj = this.resolveResponse(response);
let errorDesc = responseObj?.description || '';
return `${code}: ${errorDesc}`;
});
if (errorResponses.length > 0) {
description += '\nError Responses:\n' + errorResponses.join('\n');
}
}
// Extract return type (response schema)
const returnSchema = this.extractResponseType(operation.responses);
return {
name: methodName, // Use the generated/provided base name
description,
inputSchema,
...(returnSchema ? { returnSchema } : {}),
};
}
extractResponseType(responses) {
// Look for a success response
const successResponse = responses?.['200'] || responses?.['201'] || responses?.['202'] || responses?.['204'];
if (!successResponse)
return null;
const responseObj = this.resolveResponse(successResponse);
if (!responseObj || !responseObj.content)
return null;
if (responseObj.content['application/json']?.schema) {
const returnSchema = this.convertOpenApiSchemaToJsonSchema(responseObj.content['application/json'].schema, new Set(), false);
returnSchema['$defs'] = this.convertComponentsToJsonSchema();
// Preserve the response description if available and not already set
if (responseObj.description && !returnSchema.description) {
returnSchema.description = responseObj.description;
}
return returnSchema;
}
// If no JSON response, fallback to a generic string or known formats
if (responseObj.content['image/png'] || responseObj.content['image/jpeg']) {
return { type: 'string', format: 'binary', description: responseObj.description || '' };
}
// Fallback
return { type: 'string', description: responseObj.description || '' };
}
ensureUniqueName(name) {
if (name.length <= 64) {
return name;
}
const truncatedName = name.slice(0, 64 - 5); // Reserve space for suffix
const uniqueSuffix = this.generateUniqueSuffix();
return `${truncatedName}-${uniqueSuffix}`;
}
generateUniqueSuffix() {
this.nameCounter += 1;
return this.nameCounter.toString().padStart(4, '0');
}
// Helper to generate a name like get_public_topic_topic_v1
generateNameFromPath(method, path) {
// Remove leading/trailing slashes, replace slashes and placeholders with underscores
const parts = path.replace(/^\/|\/$/g, '').split('/').map(part => part.startsWith(':') ? part.substring(1) : part);
return `${method.toLowerCase()}_${parts.join('_')}`;
}
}
//# sourceMappingURL=parser.js.map