From e0236a907fa54fea945f350722c3e76dc3e0f383 Mon Sep 17 00:00:00 2001 From: bipproduction Date: Tue, 25 Nov 2025 15:11:22 +0800 Subject: [PATCH] tambahan --- bak/mcp_route.ts.txt | 250 ++++++++++++ bak/mcp_tool_convert.ts.txt | 381 +++++++++++++++++ src/server/lib/mcp_tool_convert.ts | 59 ++- src/server/routes/mcp_route.ts | 630 ++++++++++++++++++++--------- 4 files changed, 1101 insertions(+), 219 deletions(-) create mode 100644 bak/mcp_route.ts.txt create mode 100644 bak/mcp_tool_convert.ts.txt diff --git a/bak/mcp_route.ts.txt b/bak/mcp_route.ts.txt new file mode 100644 index 0000000..81dea97 --- /dev/null +++ b/bak/mcp_route.ts.txt @@ -0,0 +1,250 @@ +import { Elysia } from "elysia"; +import { getMcpTools } from "../lib/mcp_tool_convert"; + +var tools = [] as any[]; +const OPENAPI_URL = process.env.BUN_PUBLIC_BASE_URL + "/docs/json"; +const FILTER_TAG = "mcp"; + +if (!process.env.BUN_PUBLIC_BASE_URL) { + throw new Error("BUN_PUBLIC_BASE_URL environment variable is not set"); +} + +// ===================== +// MCP Protocol Types +// ===================== +type JSONRPCRequest = { + jsonrpc: "2.0"; + id: string | number; + method: string; + params?: any; +}; + +type JSONRPCResponse = { + jsonrpc: "2.0"; + id: string | number; + result?: any; + error?: { + code: number; + message: string; + data?: any; + }; +}; + +// ===================== +// Tool Executor +// ===================== +export async function executeTool( + tool: any, + args: Record = {}, + baseUrl: string +) { + const x = tool["x-props"] || {}; + + const method = (x.method || "GET").toUpperCase(); + const path = x.path || `/${tool.name}`; + const url = `${baseUrl}${path}`; + + const opts: RequestInit = { + method, + headers: { "Content-Type": "application/json" }, + }; + + if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) { + opts.body = JSON.stringify(args || {}); + } + + const res = await fetch(url, opts); + const contentType = res.headers.get("content-type") || ""; + const data = contentType.includes("application/json") + ? await res.json() + : await res.text(); + + return { + success: res.ok, + status: res.status, + method, + path, + data, + }; +} + +// ===================== +// MCP Handler (Async) +// ===================== +async function handleMCPRequestAsync( + request: JSONRPCRequest +): Promise { + const { id, method, params } = request; + + switch (method) { + case "initialize": + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "elysia-mcp-server", version: "1.0.0" }, + }, + }; + + case "tools/list": + return { + jsonrpc: "2.0", + id, + result: { + tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({ + name, + description, + inputSchema, + "x-props": x, + })), + }, + }; + + case "tools/call": { + const toolName = params?.name; + const tool = tools.find((t) => t.name === toolName); + + if (!tool) { + return { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Tool '${toolName}' not found` }, + }; + } + + try { + const baseUrl = + process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000"; + const result = await executeTool(tool, params?.arguments || {}, baseUrl); + const data = result.data.data; + const isObject = typeof data === "object" && data !== null; + + return { + jsonrpc: "2.0", + id, + result: { + content: [ + isObject + ? { type: "json", data: data } + : { type: "text", text: JSON.stringify(data || result.data || result) }, + ], + }, + }; + } catch (error: any) { + return { + jsonrpc: "2.0", + id, + error: { code: -32603, message: error.message }, + }; + } + } + + case "ping": + return { jsonrpc: "2.0", id, result: {} }; + + default: + return { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method '${method}' not found` }, + }; + } +} + +// ===================== +// Elysia MCP Server +// ===================== +export const MCPRoute = new Elysia({ + tags: ["MCP Server"] +}) + .post("/mcp", async ({ request, set }) => { + if (!tools.length) { + tools = await getMcpTools(OPENAPI_URL, FILTER_TAG); + } + set.headers["Content-Type"] = "application/json"; + set.headers["Access-Control-Allow-Origin"] = "*"; + + try { + const body = await request.json(); + + if (!Array.isArray(body)) { + const res = await handleMCPRequestAsync(body); + return res; + } + + const results = await Promise.all( + body.map((req) => handleMCPRequestAsync(req)) + ); + return results; + } catch (error: any) { + set.status = 400; + return { + jsonrpc: "2.0", + id: null, + error: { + code: -32700, + message: "Parse error", + data: error.message, + }, + }; + } + }) + + // Tools list (debug) + .get("/mcp/tools", async ({ set }) => { + if (!tools.length) { + + tools = await getMcpTools(OPENAPI_URL, FILTER_TAG); + } + set.headers["Access-Control-Allow-Origin"] = "*"; + return { + tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({ + name, + description, + inputSchema, + "x-props": x, + })), + }; + }) + + // MCP status + .get("/mcp/status", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + return { status: "active", timestamp: Date.now() }; + }) + + // Health check + .get("/health", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + return { status: "ok", timestamp: Date.now(), tools: tools.length }; + }) + .get("/mcp/init", async ({ set }) => { + + const _tools = await getMcpTools(OPENAPI_URL, FILTER_TAG); + tools = _tools; + return { + success: true, + message: "MCP initialized", + tools: tools.length, + }; + }) + + // CORS + .options("/mcp", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"; + set.headers["Access-Control-Allow-Headers"] = + "Content-Type,Authorization,X-API-Key"; + set.status = 204; + return ""; + }) + .options("/mcp/tools", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS"; + set.headers["Access-Control-Allow-Headers"] = + "Content-Type,Authorization,X-API-Key"; + set.status = 204; + return ""; + }); diff --git a/bak/mcp_tool_convert.ts.txt b/bak/mcp_tool_convert.ts.txt new file mode 100644 index 0000000..c55f2b3 --- /dev/null +++ b/bak/mcp_tool_convert.ts.txt @@ -0,0 +1,381 @@ +import _ from "lodash"; + +interface McpTool { + name: string; + description: string; + inputSchema: any; + "x-props": { + method: string; + path: string; + operationId?: string; + tag?: string; + deprecated?: boolean; + summary?: string; + }; +} + +/** + * Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions. + */ +export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): McpTool[] { + const tools: McpTool[] = []; + + if (!openApiJson || typeof openApiJson !== "object") { + console.warn("Invalid OpenAPI JSON"); + return tools; + } + + const paths = openApiJson.paths || {}; + + if (Object.keys(paths).length === 0) { + console.warn("No paths found in OpenAPI spec"); + return tools; + } + + for (const [path, methods] of Object.entries(paths)) { + if (!path || typeof path !== "string") continue; + if (path.startsWith("/mcp")) continue; + + if (!methods || typeof methods !== "object") continue; + + for (const [method, operation] of Object.entries(methods)) { + const validMethods = ["get", "post", "put", "delete", "patch", "head", "options"]; + if (!validMethods.includes(method.toLowerCase())) continue; + + if (!operation || typeof operation !== "object") continue; + + const tags: string[] = Array.isArray(operation.tags) ? operation.tags : []; + + if (!tags.length || !tags.some(t => + typeof t === "string" && t.toLowerCase().includes(filterTag) + )) continue; + + try { + const tool = createToolFromOperation(path, method, operation, tags); + if (tool) { + tools.push(tool); + } + } catch (error) { + console.error(`Error creating tool for ${method.toUpperCase()} ${path}:`, error); + continue; + } + } + } + + return tools; +} + +/** + * Buat MCP tool dari operation OpenAPI + */ +function createToolFromOperation( + path: string, + method: string, + operation: any, + tags: string[] +): McpTool | null { + try { + const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool"; + const name = cleanToolName(rawName); + + if (!name || name === "unnamed_tool") { + console.warn(`Invalid tool name for ${method} ${path}`); + return null; + } + + const description = + operation.description || + operation.summary || + `Execute ${method.toUpperCase()} ${path}`; + + // ✅ Extract schema berdasarkan method + let schema; + if (method.toLowerCase() === "get") { + // ✅ Untuk GET, ambil dari parameters (query/path) + schema = extractParametersSchema(operation.parameters || []); + } else { + // ✅ Untuk POST/PUT/etc, ambil dari requestBody + schema = extractRequestBodySchema(operation); + } + + 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, + }, + inputSchema, + }; + } catch (error) { + console.error(`Failed to create tool from operation:`, error); + return null; + } +} + +/** + * Extract schema dari parameters (untuk GET requests) + */ +function extractParametersSchema(parameters: any[]): any { + if (!Array.isArray(parameters) || parameters.length === 0) { + return null; + } + + const properties: any = {}; + const required: string[] = []; + + for (const param of parameters) { + if (!param || typeof param !== "object") continue; + + // ✅ Support path, query, dan header parameters + if (["path", "query", "header"].includes(param.in)) { + const paramName = param.name; + if (!paramName || typeof paramName !== "string") continue; + + properties[paramName] = { + type: param.schema?.type || "string", + description: param.description || `${param.in} parameter: ${paramName}`, + }; + + // ✅ Copy field tambahan dari schema + if (param.schema) { + const allowedFields = ["examples", "example", "default", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "format"]; + for (const field of allowedFields) { + if (param.schema[field] !== undefined) { + properties[paramName][field] = param.schema[field]; + } + } + } + + if (param.required === true) { + required.push(paramName); + } + } + } + + if (Object.keys(properties).length === 0) { + return null; + } + + return { + type: "object", + properties, + required, + }; +} + +/** + * Extract schema dari requestBody (untuk POST/PUT/etc requests) + */ +function extractRequestBodySchema(operation: any): any { + if (!operation.requestBody?.content) { + return null; + } + + const content = operation.requestBody.content; + + const contentTypes = [ + "application/json", + "multipart/form-data", + "application/x-www-form-urlencoded", + "text/plain", + ]; + + for (const contentType of contentTypes) { + if (content[contentType]?.schema) { + return content[contentType].schema; + } + } + + for (const [_, value] of Object.entries(content)) { + if (value?.schema) { + return value.schema; + } + } + + return null; +} + +/** + * Buat input schema yang valid untuk MCP + */ +function createInputSchema(schema: any): any { + const defaultSchema = { + type: "object", + properties: {}, + additionalProperties: false, + }; + + if (!schema || typeof schema !== "object") { + return defaultSchema; + } + + try { + const properties: any = {}; + const required: string[] = []; + const originalRequired = Array.isArray(schema.required) ? schema.required : []; + + if (schema.properties && typeof schema.properties === "object") { + for (const [key, prop] of Object.entries(schema.properties)) { + if (!key || typeof key !== "string") continue; + + try { + const cleanProp = cleanProperty(prop); + if (cleanProp) { + properties[key] = cleanProp; + + // ✅ PERBAIKAN: Check optional flag dengan benar + const isOptional = prop?.optional === true || prop?.optional === "true"; + const isInRequired = originalRequired.includes(key); + + // ✅ Hanya masukkan ke required jika memang required DAN bukan optional + if (isInRequired && !isOptional) { + required.push(key); + } + } + } catch (error) { + console.error(`Error cleaning property ${key}:`, error); + continue; + } + } + } + + return { + type: "object", + properties, + required, + additionalProperties: false, + }; + } catch (error) { + console.error("Error creating input schema:", error); + return defaultSchema; + } +} + +/** + * Bersihkan property dari field custom + */ +function cleanProperty(prop: any): any | null { + if (!prop || typeof prop !== "object") { + return { type: "string" }; + } + + try { + const cleaned: any = { + type: prop.type || "string", + }; + + const allowedFields = [ + "description", + "examples", + "example", + "default", + "enum", + "pattern", + "minLength", + "maxLength", + "minimum", + "maximum", + "format", + "multipleOf", + "exclusiveMinimum", + "exclusiveMaximum", + ]; + + for (const field of allowedFields) { + if (prop[field] !== undefined && prop[field] !== null) { + cleaned[field] = prop[field]; + } + } + + if (prop.properties && typeof prop.properties === "object") { + cleaned.properties = {}; + for (const [key, value] of Object.entries(prop.properties)) { + const cleanedNested = cleanProperty(value); + if (cleanedNested) { + cleaned.properties[key] = cleanedNested; + } + } + + if (Array.isArray(prop.required)) { + cleaned.required = prop.required.filter((r: any) => typeof r === "string"); + } + } + + if (prop.items) { + cleaned.items = cleanProperty(prop.items); + } + + if (Array.isArray(prop.oneOf)) { + cleaned.oneOf = prop.oneOf.map(cleanProperty).filter(Boolean); + } + if (Array.isArray(prop.anyOf)) { + cleaned.anyOf = prop.anyOf.map(cleanProperty).filter(Boolean); + } + if (Array.isArray(prop.allOf)) { + cleaned.allOf = prop.allOf.map(cleanProperty).filter(Boolean); + } + + return cleaned; + } catch (error) { + console.error("Error cleaning property:", error); + return null; + } +} + +/** + * Bersihkan nama tool + */ +function cleanToolName(name: string): string { + if (!name || typeof name !== "string") { + return "unnamed_tool"; + } + + try { + return name + .replace(/[{}]/g, "") + .replace(/[^a-zA-Z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .replace(/^(get|post|put|delete|patch|api)_/i, "") + .replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "") + .replace(/(^_|_$)/g, "") + || "unnamed_tool"; + } catch (error) { + console.error("Error cleaning tool name:", error); + return "unnamed_tool"; + } +} + +/** + * Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP + */ +export async function getMcpTools(url: string, filterTag: string): Promise { + try { + + console.log(`Fetching OpenAPI spec from: ${url}`); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const openApiJson = await response.json(); + const tools = convertOpenApiToMcpTools(openApiJson, filterTag); + + console.log(`✅ Successfully generated ${tools.length} MCP tools`); + + return tools; + } catch (error) { + console.error("Error fetching MCP tools:", error); + throw error; + } +} + diff --git a/src/server/lib/mcp_tool_convert.ts b/src/server/lib/mcp_tool_convert.ts index c55f2b3..2b6871f 100644 --- a/src/server/lib/mcp_tool_convert.ts +++ b/src/server/lib/mcp_tool_convert.ts @@ -16,8 +16,11 @@ interface McpTool { /** * Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions. + * * @param openApiJson OpenAPI JSON specification object. + * @param filterTag A string or array of strings. Operations must match at least one tag + * (case-insensitive partial match). */ -export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): McpTool[] { +export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string | string[]): McpTool[] { const tools: McpTool[] = []; if (!openApiJson || typeof openApiJson !== "object") { @@ -25,6 +28,15 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M return tools; } + // Cast filterTag to an array and normalize to lowercase for comparison + const filterTags = _.castArray(filterTag) + .filter(t => typeof t === "string" && t.trim() !== "") + .map(t => t.toLowerCase()); + + if (filterTags.length === 0) { + console.warn("Filter tag is empty or invalid. Returning all tools with tags."); + } + const paths = openApiJson.paths || {}; if (Object.keys(paths).length === 0) { @@ -34,7 +46,6 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M for (const [path, methods] of Object.entries(paths)) { if (!path || typeof path !== "string") continue; - if (path.startsWith("/mcp")) continue; if (!methods || typeof methods !== "object") continue; @@ -45,10 +56,19 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M if (!operation || typeof operation !== "object") continue; const tags: string[] = Array.isArray(operation.tags) ? operation.tags : []; + const lowerCaseTags = tags.map(t => typeof t === "string" ? t.toLowerCase() : ""); - if (!tags.length || !tags.some(t => - typeof t === "string" && t.toLowerCase().includes(filterTag) - )) continue; + // ✅ MODIFIKASI: Pengecekan filterTags + if (filterTags.length > 0) { + const isTagMatch = lowerCaseTags.some(opTag => + filterTags.some(fTag => opTag.includes(fTag)) + ); + + if (!isTagMatch) continue; + } else if (tags.length === 0) { + // Jika tidak ada filter, hanya proses operation yang memiliki tags + continue; + } try { const tool = createToolFromOperation(path, method, operation, tags); @@ -75,18 +95,20 @@ function createToolFromOperation( tags: string[] ): McpTool | null { try { - const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool"; - const name = cleanToolName(rawName); + const rawName = _.snakeCase(`${operation.operationId}` || `${method}_${path}`) || "unnamed_tool"; + const name = _.snakeCase(cleanToolName(operation.summary)) || cleanToolName(rawName); if (!name || name === "unnamed_tool") { console.warn(`Invalid tool name for ${method} ${path}`); return null; } - const description = + let description = operation.description || - operation.summary || - `Execute ${method.toUpperCase()} ${path}`; + operation.summary; + + description += `\n + Execute ${method.toUpperCase()} ${path}`; // ✅ Extract schema berdasarkan method let schema; @@ -343,9 +365,8 @@ function cleanToolName(name: string): string { .replace(/[^a-zA-Z0-9_]/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, "") - .replace(/^(get|post|put|delete|patch|api)_/i, "") - .replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "") - .replace(/(^_|_$)/g, "") + // ❗️ METHOD PREFIX TIDAK DIHAPUS LAGI (agar tidak duplicate) + .toLowerCase() || "unnamed_tool"; } catch (error) { console.error("Error cleaning tool name:", error); @@ -353,10 +374,14 @@ function cleanToolName(name: string): string { } } + /** * Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP + * * @param url URL of the OpenAPI spec. + * @param filterTag A string or array of strings. Operations must match at least one tag + * (case-insensitive partial match). */ -export async function getMcpTools(url: string, filterTag: string): Promise { +export async function getMcpTools(url: string, filterTag: string | string[]): Promise { try { console.log(`Fetching OpenAPI spec from: ${url}`); @@ -370,12 +395,12 @@ export async function getMcpTools(url: string, filterTag: string): Promise): string { + const parts: string[] = []; + for (const [k, v] of Object.entries(q)) { + if (v === undefined || v === null) continue; + if (Array.isArray(v)) { + for (const item of v) { + parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(item))}`); + } + } else if (typeof v === "object") { + parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(JSON.stringify(v))}`); + } else { + parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); + } + } + return parts.length ? `?${parts.join("&")}` : ""; +} + +/** Safely extract "useful" payload from a fetch result: + * Prefer resp.data if present, otherwise resp itself. + * If resp is a string, keep as string. + */ +function extractRaw(result: { data: any } | any) { + // If result shaped as { data: ... } prefer inner .data + if (result && typeof result === "object" && "data" in result) { + return result.data; + } + return result; +} + +/** Convert various payloads into MCP content shape */ +function convertToMcpContent(payload: any) { + if (typeof payload === "string") { + return { type: "text", text: payload }; + } + + if (payload == null) { + return { type: "text", text: String(payload) }; + } + + // If payload looks like an image/audio wrapper produced by converter + if (payload?.__mcp_type === "image" && payload.base64) { + return { type: "image", data: payload.base64, mimeType: payload.mimeType || "image/png" }; + } + if (payload?.__mcp_type === "audio" && payload.base64) { + return { type: "audio", data: payload.base64, mimeType: payload.mimeType || "audio/mpeg" }; + } + + // If already an object/array → return JSON + if (typeof payload === "object") { + return { type: "json", data: payload }; + } + + // Fallback — stringify + try { + return { type: "text", text: JSON.stringify(payload) }; + } catch { + return { type: "text", text: String(payload) }; + } +} + +/* ------------------------- + executeTool (robust) + ------------------------- */ +/** + * Execute a tool converted from OpenAPI -> expected x-props shape: + * x-props may contain: + * - method, path + * - parameters: [{ name, in, required? }] + * + * If x.parameters present, we inspect args and place them accordingly. + */ export async function executeTool( - tool: any, - args: Record = {}, - baseUrl: string + tool: any, + args: Record = {}, + baseUrl: string ) { - const x = tool["x-props"] || {}; + const x = tool["x-props"] || {}; + const method = (x.method || "GET").toUpperCase(); - const method = (x.method || "GET").toUpperCase(); - const path = x.path || `/${tool.name}`; - const url = `${baseUrl}${path}`; + // Start with provided path (may contain {param}) + let path = x.path ?? `/${tool.name}`; - const opts: RequestInit = { - method, - headers: { "Content-Type": "application/json" }, - }; + // Headers, cookies, query, body collection + const headers: Record = { + "Content-Type": "application/json", + ...(x.defaultHeaders || {}), + }; + const query: Record = {}; + const cookies: string[] = []; + let bodyPayload: any = undefined; - if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) { - opts.body = JSON.stringify(args || {}); + // If parameters described, map args accordingly + if (Array.isArray(x.parameters)) { + for (const p of x.parameters) { + try { + const name: string = p.name; + const value = args?.[name]; + + // skip undefined unless required — we let API validate required semantics + if (value === undefined) continue; + + switch (p.in) { + case "path": + if (path.includes(`{${name}}`)) { + path = path.replace(new RegExp(`{${name}}`, "g"), encodeURIComponent(String(value))); + } else { + // fallback to query + query[name] = value; + } + break; + + case "query": + query[name] = value; + break; + + case "header": + headers[name] = value; + break; + + case "cookie": + cookies.push(`${name}=${value}`); + break; + + case "body": + case "requestBody": + bodyPayload = value; + break; + + default: + // unknown location -> place into body + bodyPayload = bodyPayload ?? {}; + bodyPayload[name] = value; + break; + } + } catch (err) { + // best-effort: skip problematic param + console.warn(`[MCP] Skipping parameter ${String(p?.name)} due to error:`, err); + } } + } else { + // no param descriptions: assume all args are body + bodyPayload = Object.keys(args || {}).length ? args : undefined; + } - const res = await fetch(url, opts); - const contentType = res.headers.get("content-type") || ""; - const data = contentType.includes("application/json") - ? await res.json() - : await res.text(); + if (cookies.length) { + headers["Cookie"] = cookies.join("; "); + } - return { - success: res.ok, - status: res.status, - method, - path, - data, - }; + // Build full URL + const urlBase = baseUrl || process.env.BUN_PUBLIC_BASE_URL!; + let url = joinBasePath(urlBase, path); + const qs = buildQueryString(query); + if (qs) url += qs; + + // Build RequestInit + const opts: RequestInit & { headers?: Record } = { method, headers }; + + // Body handling for applicable methods + const bodyMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); + const contentTypeLower = (headers["Content-Type"] || "").toLowerCase(); + + if (bodyMethods.has(method) && bodyPayload !== undefined) { + // Support tiny formdata marker shape: { __formdata: true, entries: [ [k,v], ... ] } + if (bodyPayload && bodyPayload.__formdata === true && Array.isArray(bodyPayload.entries)) { + const form = new FormData(); + for (const [k, v] of bodyPayload.entries) { + form.append(k, v as any); + } + // Let fetch set boundary + delete opts.headers!["Content-Type"]; + opts.body = form as any; + } else if (contentTypeLower.includes("application/x-www-form-urlencoded")) { + opts.body = new URLSearchParams(bodyPayload as Record).toString(); + } else if (contentTypeLower.includes("multipart/form-data")) { + // If caller explicitly requested multipart but didn't pass FormData — convert object to form + const form = new FormData(); + if (typeof bodyPayload === "object") { + for (const [k, v] of Object.entries(bodyPayload)) { + form.append(k, (v as any) as any); + } + } else { + form.append("payload", String(bodyPayload)); + } + delete opts.headers!["Content-Type"]; + opts.body = form as any; + } else { + // Default JSON + opts.body = JSON.stringify(bodyPayload); + } + } + + // Execute fetch + console.log(`[MCP] → ${method} ${url}`); + const res = await fetch(url, opts); + + const resContentType = (res.headers.get("content-type") || "").toLowerCase(); + + let data: any; + try { + if (resContentType.includes("application/json")) { + data = await res.json(); + } else { + data = await res.text(); + } + } catch (err) { + // fallback to text + try { + data = await res.text(); + } catch { + data = null; + } + } + + return { + success: res.ok, + status: res.status, + method, + url, + path, + headers: res.headers, + data, + }; } -// ===================== -// MCP Handler (Async) -// ===================== -async function handleMCPRequestAsync( - request: JSONRPCRequest -): Promise { - const { id, method, params } = request; +/* ------------------------- + JSON-RPC Handler + ------------------------- */ +async function handleMCPRequestAsync(request: JSONRPCRequest): Promise { + const { id, method, params } = request; - switch (method) { - case "initialize": + const makeError = (code: number, message: string, data?: any): JSONRPCResponse => ({ + jsonrpc: "2.0", + id: id ?? null, + error: { code, message, data }, + }); + + switch (method) { + case "initialize": + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "elysia-mcp-server", version: "1.0.0" }, + }, + }; + + case "tools/list": + return { + jsonrpc: "2.0", + id, + result: { + tools: tools.map((t) => { + const inputSchema = + typeof t.inputSchema === "object" && t.inputSchema?.type === "object" + ? t.inputSchema + : { type: "object", properties: {}, required: [] }; return { - jsonrpc: "2.0", - id, - result: { - protocolVersion: "2024-11-05", - capabilities: { tools: {} }, - serverInfo: { name: "elysia-mcp-server", version: "1.0.0" }, - }, + name: t.name, + description: t.description || "No description provided", + inputSchema, + "x-props": t["x-props"], }; + }), + }, + }; - case "tools/list": - return { - jsonrpc: "2.0", - id, - result: { - tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({ - name, - description, - inputSchema, - "x-props": x, - })), - }, - }; + case "tools/call": { + const toolName = params?.name; + const tool = tools.find((t) => t.name === toolName); + if (!tool) return makeError(-32601, `Tool '${toolName}' not found`); - case "tools/call": { - const toolName = params?.name; - const tool = tools.find((t) => t.name === toolName); + try { + const baseUrl = (params?.credentials?.baseUrl as string) || process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000"; + const args = params?.arguments || {}; - if (!tool) { - return { - jsonrpc: "2.0", - id, - error: { code: -32601, message: `Tool '${toolName}' not found` }, - }; - } + const result = await executeTool(tool, args, baseUrl); - try { - const baseUrl = - process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000"; - const result = await executeTool(tool, params?.arguments || {}, baseUrl); - const data = result.data.data; - const isObject = typeof data === "object" && data !== null; + // Extract the meaningful payload (prefer nested .data if present) + const raw = extractRaw(result.data); - return { - jsonrpc: "2.0", - id, - result: { - content: [ - isObject - ? { type: "json", data: data } - : { type: "text", text: JSON.stringify(data || result.data || result) }, - ], - }, - }; - } catch (error: any) { - return { - jsonrpc: "2.0", - id, - error: { code: -32603, message: error.message }, - }; - } - } + // Normalize content shape consistently: + const contentItem = convertToMcpContent(raw ?? result.data ?? result); - case "ping": - return { jsonrpc: "2.0", id, result: {} }; - - default: - return { - jsonrpc: "2.0", - id, - error: { code: -32601, message: `Method '${method}' not found` }, - }; + return { + jsonrpc: "2.0", + id, + result: { + content: [contentItem], + }, + }; + } catch (err: any) { + // avoid leaking secrets — small debug + const dbg = { message: err?.message }; + return makeError(-32603, err?.message ?? "Internal error", dbg); + } } + + case "ping": + return { jsonrpc: "2.0", id, result: {} }; + + default: + return makeError(-32601, `Method '${method}' not found`); + } } -// ===================== -// Elysia MCP Server -// ===================== -export const MCPRoute = new Elysia({ - tags: ["MCP Server"] -}) - .post("/mcp", async ({ request, set }) => { - if (!tools.length) { - tools = await getMcpTools(OPENAPI_URL, FILTER_TAG); - } - set.headers["Content-Type"] = "application/json"; - set.headers["Access-Control-Allow-Origin"] = "*"; +/* ------------------------- + Elysia App & Routes + ------------------------- */ +export const MCPRoute = new Elysia({ tags: ["MCP Server"] }) + .post("/mcp", async ({ request, set }) => { + set.headers["Content-Type"] = "application/json"; + set.headers["Access-Control-Allow-Origin"] = "*"; - try { - const body = await request.json(); + // Lazy load the tools (keeps previous behavior) + if (!tools.length) { + try { + tools = await getMcpTools(OPENAPI_URL, FILTER_TAG); + } catch (err) { + console.error("[MCP] Failed to load tools during lazy init:", err); + } + } - if (!Array.isArray(body)) { - const res = await handleMCPRequestAsync(body); - return res; - } + try { + const body = await request.json(); - const results = await Promise.all( - body.map((req) => handleMCPRequestAsync(req)) - ); - return results; - } catch (error: any) { - set.status = 400; - return { + // If batch array -> allSettled for resilience + if (Array.isArray(body)) { + const promises = body.map((req: JSONRPCRequest) => handleMCPRequestAsync(req)); + const settled = await Promise.allSettled(promises); + const responses = settled.map((s) => + s.status === "fulfilled" + ? s.value + : ({ jsonrpc: "2.0", id: null, error: { - code: -32700, - message: "Parse error", - data: error.message, + code: -32000, + message: "Unhandled handler error", + data: String((s as PromiseRejectedResult).reason), }, - }; - } - }) + } as JSONRPCResponse) + ); + return responses; + } - // Tools list (debug) - .get("/mcp/tools", async ({ set }) => { - if (!tools.length) { + const single = await handleMCPRequestAsync(body as JSONRPCRequest); + return single; + } catch (err: any) { + set.status = 400; + return { + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: "Parse error", data: err?.message ?? String(err) }, + } as JSONRPCResponse; + } + }) - tools = await getMcpTools(OPENAPI_URL, FILTER_TAG); - } - set.headers["Access-Control-Allow-Origin"] = "*"; - return { - tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({ - name, - description, - inputSchema, - "x-props": x, - })), - }; - }) + /* Debug / management endpoints */ + .get("/mcp/tools", async ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + if (!tools.length) { + try { + tools = await getMcpTools(OPENAPI_URL, FILTER_TAG); + } catch (err) { + console.error("[MCP] Failed to load tools for /mcp/tools:", err); + } + } + return { + tools: tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + "x-props": t["x-props"], + })), + }; + }) - // MCP status - .get("/mcp/status", ({ set }) => { - set.headers["Access-Control-Allow-Origin"] = "*"; - return { status: "active", timestamp: Date.now() }; - }) + .get("/mcp/status", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + return { status: "active", timestamp: Date.now() }; + }) - // Health check - .get("/health", ({ set }) => { - set.headers["Access-Control-Allow-Origin"] = "*"; - return { status: "ok", timestamp: Date.now(), tools: tools.length }; - }) - .get("/mcp/init", async ({ set }) => { + .get("/health", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + return { status: "ok", timestamp: Date.now(), tools: tools.length }; + }) - const _tools = await getMcpTools(OPENAPI_URL, FILTER_TAG); - tools = _tools; - return { - success: true, - message: "MCP initialized", - tools: tools.length, - }; - }) + // Force re-init (useful for admin / CI) + .get("/mcp/init", async ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + try { + tools = await getMcpTools(OPENAPI_URL, FILTER_TAG); + return { success: true, message: "MCP initialized", tools: tools.length }; + } catch (err) { + set.status = 500; + return { success: false, message: "Failed to initialize tools", error: String(err) }; + } + }) - // CORS - .options("/mcp", ({ set }) => { - set.headers["Access-Control-Allow-Origin"] = "*"; - set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"; - set.headers["Access-Control-Allow-Headers"] = - "Content-Type,Authorization,X-API-Key"; - set.status = 204; - return ""; - }) - .options("/mcp/tools", ({ set }) => { - set.headers["Access-Control-Allow-Origin"] = "*"; - set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS"; - set.headers["Access-Control-Allow-Headers"] = - "Content-Type,Authorization,X-API-Key"; - set.status = 204; - return ""; - }); + /* CORS preflight */ + .options("/mcp", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"; + set.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization,X-API-Key"; + set.status = 204; + return ""; + }) + .options("/mcp/tools", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS"; + set.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization,X-API-Key"; + set.status = 204; + return ""; + }); + +/* ------------------------- + End + ------------------------- */