update version

This commit is contained in:
bipproduction
2025-11-20 16:17:09 +08:00
parent 47074ccb7c
commit 5056f40876
3 changed files with 374 additions and 805 deletions

View File

@@ -1,393 +1,33 @@
import type { import type {
INodeType, ICredentialType,
INodeTypeDescription, INodeProperties,
IExecuteFunctions,
ILoadOptionsFunctions,
INodePropertyOptions,
} from "n8n-workflow"; } from "n8n-workflow";
import axios from "axios"; export class OpenApiCredential implements ICredentialType {
name = "openApiNodeApi";
interface OpenAPISpec { displayName = "OpenApiNode (Bearer Token)";
paths: Record<string, any>; documentationUrl = "https://docs.n8n.io/nodes/n8n-nodes-openapi-node/openapi-node";
components?: Record<string, any>;
servers?: Array<{ url: string }>; properties: INodeProperties[] = [
} {
displayName: "Base URL",
const openApiCache: Map<string, OpenAPISpec> = new Map(); name: "baseUrl",
type: "string",
async function loadOpenAPISpecCached(url: string): Promise<OpenAPISpec> { default: "",
if (openApiCache.has(url)) return openApiCache.get(url)!; placeholder: "https://api.example.com",
const res = await axios.get(url, { timeout: 20000 }); description: "Enter the base API URL without trailing slash",
const spec: OpenAPISpec = res.data; required: true,
openApiCache.set(url, spec);
return spec;
}
function resolveRef(obj: any, spec: OpenAPISpec): any {
if (!obj || typeof obj !== "object") return obj;
if (obj.$ref && typeof obj.$ref === "string" && obj.$ref.startsWith("#/")) {
const refPath = obj.$ref.replace(/^#\//, "").split("/");
let cur: any = spec;
for (const seg of refPath) cur = cur?.[seg];
return cur || obj;
}
return obj;
}
export class OpenApiNode implements INodeType {
description: INodeTypeDescription = {
displayName: "OpenApiNode - Dynamic OpenAPI",
name: "openApiNode",
icon: "file:icon.svg",
group: ["transform"],
version: 1,
subtitle: "={{$parameter['operation'] || 'select operation'}}",
description: "Dynamic UI n8n node generated from OpenAPI",
defaults: { name: "OpenApiNode" },
inputs: ["main"],
outputs: ["main"],
credentials: [
{ name: "openApiNodeApi", required: false },
],
properties: [
{
displayName: "OpenAPI JSON URL",
name: "openapiUrl",
type: "string",
default: "",
required: true,
},
{
displayName: "Tag Filter",
name: "tagFilter",
type: "options",
default: "",
description: "Filter operations based on OpenAPI tags",
typeOptions: {
loadOptionsMethod: "loadTags",
loadOptionsDependsOn: ["openapiUrl"],
},
},
{
displayName: "Operation",
name: "operation",
type: "options",
typeOptions: {
loadOptionsMethod: "loadOperations",
loadOptionsDependsOn: ["openapiUrl", "tagFilter"],
},
default: "",
required: true,
},
{
displayName: "Parameters (Form)",
name: "parametersForm",
type: "fixedCollection",
placeholder: "Add Parameter",
typeOptions: { multipleValues: true },
default: {},
options: [
{
name: "parameter",
displayName: "Parameter",
values: [
{
displayName: "Name",
name: "name",
type: "options",
typeOptions: {
loadOptionsMethod: "loadParameterNames",
loadOptionsDependsOn: ["openapiUrl", "operation"],
},
default: "",
},
{
displayName: "In",
name: "in",
type: "options",
options: [
{ name: "query", value: "query" },
{ name: "path", value: "path" },
{ name: "header", value: "header" },
{ name: "body", value: "body" },
],
default: "query",
},
{
displayName: "Value",
name: "value",
type: "string",
default: "",
},
],
},
],
},
],
};
methods = {
loadOptions: {
// ============================
// LOAD TAGS
// ============================
async loadTags(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const url = (this.getNodeParameter("openapiUrl", "") as string).trim();
if (!url) return [{ name: "Enter URL first", value: "" }];
try {
const spec = await loadOpenAPISpecCached(url);
const tags = new Set<string>();
for (const path of Object.keys(spec.paths || {})) {
const pathObj = spec.paths[path];
for (const method of Object.keys(pathObj || {})) {
const op = pathObj[method];
if (Array.isArray(op.tags)) {
for (const t of op.tags) tags.add(t);
}
}
}
return [...tags].map((t) => ({
name: t,
value: t,
description: `Filter only operations with tag: ${t}`,
}));
} catch (err: any) {
return [{ name: `Error: ${err.message}`, value: "" }];
}
},
// ============================
// LOAD OPERATIONS
// ============================
async loadOperations(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const url = (this.getNodeParameter("openapiUrl", "") as string).trim();
const tagFilter = (this.getNodeParameter("tagFilter", "") as string).trim();
if (!url) return [{ name: "Provide openapiUrl first", value: "" }];
try {
const spec = await loadOpenAPISpecCached(url);
const ops: INodePropertyOptions[] = [];
for (const path of Object.keys(spec.paths || {})) {
const pathObj = spec.paths[path];
for (const method of Object.keys(pathObj || {})) {
const op = pathObj[method];
if (tagFilter && (!op.tags || !op.tags.includes(tagFilter))) {
continue; // apply tag filter
}
const opId =
op.operationId ||
`${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`;
const methodLabel = method.toUpperCase();
const summary = op.summary || opId;
const description = op.description || "No description provided.";
ops.push({
name: `[${methodLabel}] ${path}${summary}`,
value: opId,
description: `${summary}\n\n${description}\n\nTags: ${
op.tags ? op.tags.join(", ") : "None"
}\nOperation ID: ${opId}\nMethod: ${methodLabel}\nPath: ${path}`,
});
}
}
return ops.length ? ops : [{ name: "No operations found", value: "" }];
} catch (err: any) {
return [{ name: `Error: ${err.message}`, value: "" }];
}
},
// ============================
// LOAD PARAMETER NAMES
// ============================
async loadParameterNames(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const url = (this.getNodeParameter("openapiUrl", "") as string).trim();
const opId = (this.getNodeParameter("operation", "") as string).trim();
if (!url || !opId) return [{ name: "Select operation first", value: "" }];
try {
const spec = await loadOpenAPISpecCached(url);
const out: INodePropertyOptions[] = [];
outer: for (const path of Object.keys(spec.paths || {})) {
const pathObj = spec.paths[path];
for (const method of Object.keys(pathObj || {})) {
const op = pathObj[method];
const id =
op.operationId ||
`${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`;
if (id !== opId) continue;
const params = [
...(pathObj.parameters || []),
...(op.parameters || []),
];
const seen = new Set<string>();
for (const p of params) {
if (!seen.has(p.name)) {
seen.add(p.name);
out.push({
name: `${p.name} (in=${p.in})`,
value: p.name,
});
}
}
// Body fields
const bodySchema =
op.requestBody?.content?.["application/json"]?.schema;
if (bodySchema) {
const resolved = resolveRef(bodySchema, spec);
const props = resolved.properties || {};
for (const propName of Object.keys(props)) {
if (!seen.has(propName)) {
seen.add(propName);
out.push({
name: `${propName} (body)`,
value: propName,
});
}
}
}
break outer;
}
}
return out;
} catch (err: any) {
return [{ name: `Error: ${err.message}`, value: "" }];
}
},
}, },
}; {
displayName: "Bearer Token",
// ============================ name: "token",
// EXECUTE METHOD type: "string",
// ============================ typeOptions: {
async execute(this: IExecuteFunctions) { password: true,
const items = this.getInputData(); },
const returnData: any[] = []; default: "",
description: "Enter the Bearer authentication token (without 'Bearer ' prefix)",
const creds = (await this.getCredentials?.("openApiNodeApi")) as required: true,
| { baseUrl?: string; token?: string; apiKey?: string } },
| undefined; ];
}
for (let i = 0; i < items.length; i++) {
try {
const openapiUrl = this.getNodeParameter("openapiUrl", i, "") as string;
const operationId = this.getNodeParameter("operation", i, "") as string;
const spec = await loadOpenAPISpecCached(openapiUrl);
let foundOp: any = null;
let foundPath = "";
let foundMethod = "";
outer: for (const path of Object.keys(spec.paths || {})) {
const pathObj = spec.paths[path];
for (const method of Object.keys(pathObj || {})) {
const op = pathObj[method];
const id =
op.operationId ||
`${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`;
if (id === operationId) {
foundOp = op;
foundPath = path;
foundMethod = method.toLowerCase();
break outer;
}
}
}
if (!foundOp) throw new Error("Operation not found");
const paramsForm = this.getNodeParameter("parametersForm", i, {}) as any;
let queryParams: Record<string, any> = {};
let bodyParams: Record<string, any> = {};
const pathParams = [
...(spec.paths[foundPath].parameters || []),
...(foundOp.parameters || []),
];
if (paramsForm && Array.isArray(paramsForm.parameter)) {
for (const p of paramsForm.parameter) {
if (p.in === "query") queryParams[p.name] = p.value;
else if (p.in === "body") bodyParams[p.name] = p.value;
else if (p.in === "header") queryParams[p.name] = p.value;
}
}
let baseUrl =
creds?.baseUrl ||
spec.servers?.[0]?.url ||
"";
baseUrl = baseUrl.replace(/\/+$/, "");
let url = baseUrl + foundPath;
for (const p of pathParams) {
if (p.in === "path") {
const item = paramsForm.parameter.find((x: any) => x.name === p.name);
const val = item?.value;
if (!val) throw new Error(`Missing path param ${p.name}`);
url = url.replace(`{${p.name}}`, encodeURIComponent(val));
}
}
const headers: Record<string, any> = {
"Content-Type": "application/json",
};
if (creds?.token) headers["Authorization"] = `Bearer ${creds.token}`;
if (creds?.apiKey) headers["X-API-Key"] = creds.apiKey;
const axiosConfig: any = {
method: foundMethod,
url,
headers,
params: queryParams,
timeout: 30000,
};
// GET/HEAD must not have body
if (!(foundMethod === "get" || foundMethod === "head")) {
axiosConfig.data = Object.keys(bodyParams).length ? bodyParams : undefined;
}
const resp = await axios(axiosConfig);
returnData.push({ json: resp.data });
} catch (err: any) {
if (this.continueOnFail && this.continueOnFail()) {
returnData.push({
json: {
error: true,
message: err.message,
response: err.response?.data,
},
});
continue;
}
throw err;
}
}
return [returnData];
}
}

View File

@@ -1,432 +1,361 @@
import type { // mcp_tool_convert.ts
INodeType, import _ from "lodash";
INodeTypeDescription,
IExecuteFunctions,
ILoadOptionsFunctions,
INodePropertyOptions,
} from "n8n-workflow";
import axios from "axios";
interface OpenAPISpec { /**
paths: Record<string, any>; * ============================
components?: Record<string, any>; * Types
servers?: Array<{ url: string }>; * ============================
} */
interface McpTool {
const openApiCache: Map<string, OpenAPISpec> = new Map(); name: string;
description: string;
// --------------------------------------------------------------------- inputSchema: any;
// Cache Loader "x-props": {
// --------------------------------------------------------------------- method: string;
path: string;
async function loadOpenAPISpecCached(url: string): Promise<OpenAPISpec> { operationId?: string;
if (openApiCache.has(url)) return openApiCache.get(url)!; tag?: string;
const res = await axios.get(url, { timeout: 20000 }); deprecated?: boolean;
const spec: OpenAPISpec = res.data; summary?: string;
openApiCache.set(url, spec); parameters?: any[];
return spec;
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
function getDefaultValue(type: string | undefined, schema?: any): any {
if (schema?.example !== undefined) return schema.example;
if (schema?.default !== undefined) return schema.default;
switch (type) {
case "string": return "";
case "number":
case "integer": return 0;
case "boolean": return false;
case "array": return [];
case "object": return {};
default: return null;
}
}
function resolveRef(obj: any, spec: OpenAPISpec): any {
if (!obj || typeof obj !== "object") return obj;
if (obj.$ref && typeof obj.$ref === "string" && obj.$ref.startsWith("#/")) {
const refPath = obj.$ref.replace(/^#\//, "").split("/");
let cur: any = spec;
for (const seg of refPath) cur = cur?.[seg];
return cur || obj;
}
return obj;
}
// ---------------------------------------------------------------------
// Node Definition
// ---------------------------------------------------------------------
export class OpenApiNode implements INodeType {
description: INodeTypeDescription = {
displayName: "OpenApiNode - Dynamic OpenAPI",
name: "openApiNode",
icon: "file:icon.svg",
group: ["transform"],
version: 1,
subtitle: "={{$parameter['operation'] || 'select operation'}}",
description: "Dynamic UI n8n node generated from OpenAPI",
defaults: { name: "OpenApiNode" },
inputs: ["main"],
outputs: ["main"],
credentials: [{ name: "openApiNodeApi", required: false }],
// -----------------------------------------------------------------
// All Editable Properties
// -----------------------------------------------------------------
properties: [
{
displayName: "OpenAPI JSON URL",
name: "openapiUrl",
type: "string",
default: "",
required: true,
},
// New: Tag Filter
{
displayName: "Tag Filter (Optional)",
name: "tagFilter",
type: "options",
default: "",
description: "Filter operations by tag",
typeOptions: {
loadOptionsMethod: "loadTags",
loadOptionsDependsOn: ["openapiUrl"],
},
},
{
displayName: "Operation",
name: "operation",
type: "options",
default: "",
required: true,
typeOptions: {
loadOptionsMethod: "loadOperations",
loadOptionsDependsOn: ["openapiUrl", "tagFilter"],
},
},
{
displayName: "Parameters (Form)",
name: "parametersForm",
type: "fixedCollection",
placeholder: "Add Parameter",
typeOptions: { multipleValues: true },
default: {},
options: [
{
name: "parameter",
displayName: "Parameter",
values: [
{
displayName: "Name",
name: "name",
type: "options",
default: "",
typeOptions: {
loadOptionsMethod: "loadParameterNames",
loadOptionsDependsOn: ["openapiUrl", "operation"],
},
},
{
displayName: "In",
name: "in",
type: "options",
default: "query",
options: [
{ name: "Query", value: "query" },
{ name: "Path", value: "path" },
{ name: "Header", value: "header" },
{ name: "Body", value: "body" },
],
},
{
displayName: "Value",
name: "value",
type: "string",
default: "",
},
],
},
],
},
],
}; };
}
// --------------------------------------------------------------------- /**
// Methods * ============================
// --------------------------------------------------------------------- * Public: convertOpenApiToMcpTools
* ============================
* Convert OpenAPI 3.x spec → MCP Tools
* - filterTag : match against operation tags
*/
export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): McpTool[] {
const tools: McpTool[] = [];
methods = { if (!openApiJson || typeof openApiJson !== "object") {
loadOptions: { console.warn("Invalid OpenAPI JSON");
// ------------------------------------------------------------- return tools;
// Load Tags }
// -------------------------------------------------------------
async loadTags(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const url = (this.getNodeParameter("openapiUrl", "") as string).trim();
if (!url) return [{ name: "Provide openapiUrl first", value: "" }];
try { const paths = openApiJson.paths || {};
const spec = await loadOpenAPISpecCached(url); if (Object.keys(paths).length === 0) {
const tagSet = new Set<string>(); console.warn("No paths found in OpenAPI spec");
return tools;
}
for (const path of Object.keys(spec.paths || {})) { for (const [path, methods] of Object.entries<any>(paths)) {
const pathObj = spec.paths[path]; if (!methods || typeof methods !== "object") continue;
for (const method of Object.keys(pathObj || {})) {
const op = pathObj[method];
(op.tags || []).forEach((t: string) => tagSet.add(t));
}
}
return [...tagSet].map(tag => ({ for (const [method, operation] of Object.entries<any>(methods)) {
name: tag, const valid = ["get", "post", "put", "delete", "patch", "head", "options"];
value: tag, if (!valid.includes(method.toLowerCase())) continue;
}));
} catch (err: any) {
return [{ name: `Error: ${err.message}`, value: "" }];
}
},
// ------------------------------------------------------------- if (!operation || typeof operation !== "object") continue;
// Load Operations (with tag filter)
// -------------------------------------------------------------
async loadOperations(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const url = (this.getNodeParameter("openapiUrl", "") as string).trim();
const selectedTag = (this.getNodeParameter("tagFilter", "") as string).trim();
if (!url) return [{ name: "Provide openapiUrl first", value: "" }]; const tags = Array.isArray(operation.tags) ? operation.tags : [];
try { // Tag filter
const spec = await loadOpenAPISpecCached(url); if (
const ops: INodePropertyOptions[] = []; filterTag &&
(!tags.length ||
!tags.some((t: string) =>
t?.toLowerCase().includes(filterTag.toLowerCase())
))
) {
continue;
}
for (const path of Object.keys(spec.paths || {})) {
const pathObj = spec.paths[path];
for (const method of Object.keys(pathObj || {})) {
const op = pathObj[method];
// Apply tag filter
if (selectedTag && !(op.tags || []).includes(selectedTag)) continue;
const opId =
op.operationId ||
`${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`;
const title = op.summary || opId;
ops.push({
name: `${method.toUpperCase()} ${path}`,
value: opId,
description: title,
});
}
}
return ops.length
? ops
: [{ name: "No operations found for selected tag", value: "" }];
} catch (err: any) {
return [{ name: `Error: ${err.message}`, value: "" }];
}
},
// -------------------------------------------------------------
// Load Parameter Names
// -------------------------------------------------------------
async loadParameterNames(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const url = (this.getNodeParameter("openapiUrl", "") as string).trim();
const opId = (this.getNodeParameter("operation", "") as string).trim();
if (!url || !opId) return [{ name: "Select operation first", value: "" }];
try {
const spec = await loadOpenAPISpecCached(url);
const out: INodePropertyOptions[] = [];
const seen = new Set<string>();
outer: for (const path of Object.keys(spec.paths || {})) {
const pathObj = spec.paths[path];
for (const method of Object.keys(pathObj || {})) {
const op = pathObj[method];
const id =
op.operationId ||
`${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`;
if (id !== opId) continue;
const params = [
...(pathObj.parameters || []),
...(op.parameters || []),
];
for (const p of params) {
if (!seen.has(p.name)) {
seen.add(p.name);
out.push({
name: `${p.name} (in=${p.in})`,
value: p.name
});
}
}
const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
if (bodySchema) {
const resolved = resolveRef(bodySchema, spec);
const props = resolved.properties || {};
for (const propName of Object.keys(props)) {
if (!seen.has(propName)) {
seen.add(propName);
out.push({
name: `${propName} (body)`,
value: propName,
});
}
}
}
break outer;
}
}
return out;
} catch (err: any) {
return [{ name: `Error: ${err.message}`, value: "" }];
}
},
},
};
// ---------------------------------------------------------------------
// Execute API Call
// ---------------------------------------------------------------------
async execute(this: IExecuteFunctions) {
const items = this.getInputData();
const returnData: any[] = [];
const creds = (await this.getCredentials?.("openApiNodeApi")) as
| { baseUrl?: string; token?: string; apiKey?: string }
| undefined;
for (let i = 0; i < items.length; i++) {
try { try {
const openapiUrl = this.getNodeParameter("openapiUrl", i, "") as string; const tool = createToolFromOperation(path, method, operation, tags);
const operationId = this.getNodeParameter("operation", i, "") as string; if (tool) tools.push(tool);
const spec = await loadOpenAPISpecCached(openapiUrl); } catch (err) {
console.error(`Error building tool for ${method.toUpperCase()} ${path}`, err);
let foundOp: any = null;
let foundPath = "";
let foundMethod = "";
outer: for (const path of Object.keys(spec.paths || {})) {
const pathObj = spec.paths[path];
for (const method of Object.keys(pathObj || {})) {
const op = pathObj[method];
const id =
op.operationId ||
`${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`;
if (id === operationId) {
foundOp = op;
foundPath = path;
foundMethod = method.toLowerCase();
break outer;
}
}
}
if (!foundOp) throw new Error("Operation not found");
const paramsForm = this.getNodeParameter("parametersForm", i, {}) as any;
const queryParams: Record<string, any> = {};
const bodyParams: Record<string, any> = {};
const pathParams = [
...(spec.paths[foundPath].parameters || []),
...(foundOp.parameters || []),
];
if (paramsForm?.parameter) {
for (const p of paramsForm.parameter) {
if (p.in === "query") queryParams[p.name] = p.value;
else if (p.in === "body") bodyParams[p.name] = p.value;
else if (p.in === "header") queryParams[p.name] = p.value;
}
}
// Build final URL
let baseUrl =
creds?.baseUrl ||
spec.servers?.[0]?.url ||
"";
baseUrl = baseUrl.replace(/\/+$/, "");
let url = baseUrl + foundPath;
for (const p of pathParams) {
if (p.in === "path") {
const item = paramsForm.parameter.find((x: any) => x.name === p.name);
const val = item?.value;
if (!val) throw new Error(`Missing path param: ${p.name}`);
url = url.replace(`{${p.name}}`, encodeURIComponent(val));
}
}
const headers: Record<string, any> = {
"Content-Type": "application/json",
};
if (creds?.token) headers["Authorization"] = `Bearer ${creds.token}`;
if (creds?.apiKey) headers["X-API-Key"] = creds.apiKey;
const axiosConfig: any = {
method: foundMethod,
url,
headers,
params: queryParams,
timeout: 30000,
};
// GET/HEAD cannot have body
if (!(foundMethod === "get" || foundMethod === "head")) {
axiosConfig.data = Object.keys(bodyParams).length
? bodyParams
: undefined;
}
const resp = await axios(axiosConfig);
returnData.push({ json: resp.data });
} catch (err: any) {
if (this.continueOnFail?.()) {
returnData.push({
json: {
error: true,
message: err.message,
response: err.response?.data,
},
});
} else {
throw err;
}
} }
} }
}
return [returnData]; return tools;
}
/**
* ============================
* Build Tool from Operation
* ============================
*/
function createToolFromOperation(
path: string,
method: string,
operation: any,
tags: string[]
): McpTool | null {
const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool";
const name = cleanToolName(rawName);
if (name === "unnamed_tool") {
console.warn(`Invalid tool name: ${method} ${path}`);
return null;
}
const description =
operation.description ||
operation.summary ||
`Execute ${method.toUpperCase()} ${path}`;
// Build executor parameter array
const parameters: any[] = [];
if (Array.isArray(operation.parameters)) {
for (const p of operation.parameters) {
if (!p || typeof p !== "object") continue;
parameters.push({
name: p.name,
in: p.in,
required: !!p.required,
description: p.description,
schema: p.schema || { type: "string" },
});
}
}
// Synthetic requestBody param
if (operation.requestBody?.content) {
const schema = extractPreferredContentSchema(operation.requestBody.content);
parameters.push({
name: "body",
in: "requestBody",
required: !!operation.requestBody.required,
schema: schema || { type: "object" },
description: operation.requestBody.description || "Request body",
});
}
// Build input schema
let schema: any = null;
const lower = method.toLowerCase();
if (["get", "delete", "head"].includes(lower)) {
schema = extractParametersSchema(operation.parameters || []);
} else {
schema = extractRequestBodySchema(operation) ||
extractParametersSchema(operation.parameters || []);
}
const inputSchema = createInputSchema(schema);
return {
name,
description,
"x-props": {
method: method.toUpperCase(),
path,
operationId: operation.operationId,
tag: tags[0],
deprecated: operation.deprecated || false,
summary: operation.summary,
parameters,
},
inputSchema,
};
}
/**
* ============================
* Extract Preferred Content Schema
* ============================
*/
function extractPreferredContentSchema(content: any): any {
if (!content) return null;
const preferred = [
"application/json",
"multipart/form-data",
"application/x-www-form-urlencoded",
"text/plain",
];
for (const type of preferred) {
if (content[type]?.schema) return content[type].schema;
}
const first = Object.values<any>(content)[0];
return first?.schema || null;
}
/**
* ============================
* Extract Parameter Schema (GET/DELETE)
* ============================
*/
function extractParametersSchema(parameters: any[]): any | null {
if (!parameters.length) return null;
const properties: any = {};
const required: string[] = [];
for (const param of parameters) {
if (!["path", "query", "header"].includes(param.in)) continue;
const name = param.name;
if (!name) continue;
const schema = param.schema || { type: "string" };
properties[name] = {
type: schema.type || "string",
description: param.description || `${param.in} parameter: ${name}`,
...extractSchemaDetails(schema),
};
if (param.required) required.push(name);
}
if (!Object.keys(properties).length) return null;
return { type: "object", properties, required };
}
/**
* ============================
* Extract RequestBody Schema
* ============================
*/
function extractRequestBodySchema(operation: any): any | null {
return extractPreferredContentSchema(operation?.requestBody?.content);
}
/**
* ============================
* Create MCP Input Schema
* ============================
*/
function createInputSchema(schema: any): any {
if (!schema || typeof schema !== "object") {
return { type: "object", properties: {}, additionalProperties: false };
}
const properties: any = {};
const required: string[] = Array.isArray(schema.required) ? [...schema.required] : [];
if (schema.properties) {
for (const [key, prop] of Object.entries<any>(schema.properties)) {
const cleaned = cleanProperty(prop);
if (cleaned) properties[key] = cleaned;
}
}
if (schema.type === "array" && schema.items) {
properties.items = cleanProperty(schema.items) || { type: "string" };
}
return {
type: "object",
properties,
required,
additionalProperties: false,
};
}
/**
* ============================
* Clean Individual Schema Property
* ============================
*/
function cleanProperty(prop: any): any | null {
if (!prop || typeof prop !== "object") return { type: "string" };
const out: any = { type: prop.type || "string" };
Object.assign(out, extractSchemaDetails(prop));
if (prop.properties) {
out.properties = {};
for (const [k, v] of Object.entries<any>(prop.properties)) {
const cleaned = cleanProperty(v);
if (cleaned) out.properties[k] = cleaned;
}
if (Array.isArray(prop.required)) {
out.required = prop.required.filter((r: any) => typeof r === "string");
}
}
if (prop.items) {
out.items = cleanProperty(prop.items);
}
if (Array.isArray(prop.oneOf)) out.oneOf = prop.oneOf.map(cleanProperty);
if (Array.isArray(prop.anyOf)) out.anyOf = prop.anyOf.map(cleanProperty);
if (Array.isArray(prop.allOf)) out.allOf = prop.allOf.map(cleanProperty);
return out;
}
/**
* ============================
* Extract Allowed Schema Fields
* ============================
*/
function extractSchemaDetails(schema: any) {
const allowed = [
"description",
"examples",
"example",
"default",
"enum",
"pattern",
"minLength",
"maxLength",
"minimum",
"maximum",
"format",
"multipleOf",
"exclusiveMinimum",
"exclusiveMaximum",
];
const out: any = {};
for (const f of allowed) {
if (schema[f] !== undefined) out[f] = schema[f];
}
return out;
}
/**
* ============================
* Clean tool name safely
* ============================
*/
function cleanToolName(value: string): string {
if (!value) return "unnamed_tool";
return value
.replace(/[{}]/g, "")
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.toLowerCase() || "unnamed_tool";
}
/**
* ============================
* Public: getMcpTools
* ============================
*/
export async function getMcpTools(url: string, filterTag: string): Promise<McpTool[]> {
try {
console.log(`Fetching OpenAPI spec: ${url}`);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const tools = convertOpenApiToMcpTools(json, filterTag);
console.log(`Generated ${tools.length} MCP tools`);
return tools;
} catch (err) {
console.error("Error fetching MCP Tools:", err);
throw err;
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-nodes-openapi-node", "name": "n8n-nodes-openapi-node",
"version": "1.0.4", "version": "1.0.5",
"keywords": [ "keywords": [
"n8n", "n8n",
"n8n-nodes" "n8n-nodes"