From a8450fc0c66212e84b9dcfa1c552b5bc5e443a41 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 11 May 2026 12:43:58 +0200 Subject: [PATCH] =?UTF-8?q?MCP-Server=20f=C3=BCr=20Buchhaltung=20und=20Org?= =?UTF-8?q?anisation=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fügt einen geschützten MCP-JSON-RPC-Endpunkt mit Buchhaltungs-Tools und Aufgaben-Tools hinzu. Berechtigungen werden rollenbasiert pro Mandant geprüft und die Auth-Logik berücksichtigt nun alle Rollen eines Nutzers. --- backend/src/index.ts | 2 + backend/src/mcp/authz.ts | 88 ++++++++++++ backend/src/mcp/registry.ts | 10 ++ backend/src/mcp/result.ts | 36 +++++ backend/src/mcp/tools/accounting.ts | 194 ++++++++++++++++++++++++++ backend/src/mcp/tools/organisation.ts | 183 ++++++++++++++++++++++++ backend/src/mcp/types.ts | 36 +++++ backend/src/plugins/auth.ts | 19 +-- backend/src/routes/mcp.ts | 144 +++++++++++++++++++ 9 files changed, 703 insertions(+), 9 deletions(-) create mode 100644 backend/src/mcp/authz.ts create mode 100644 backend/src/mcp/registry.ts create mode 100644 backend/src/mcp/result.ts create mode 100644 backend/src/mcp/tools/accounting.ts create mode 100644 backend/src/mcp/tools/organisation.ts create mode 100644 backend/src/mcp/types.ts create mode 100644 backend/src/routes/mcp.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 935e500..a5be66f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,6 +30,7 @@ import userRoutes from "./routes/auth/user"; import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated"; import wikiRoutes from "./routes/wiki"; import portalContractRoutes from "./routes/portal/contracts"; +import mcpRoutes from "./routes/mcp"; //Public Links import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated"; @@ -148,6 +149,7 @@ async function main() { await subApp.register(resourceRoutes); await subApp.register(wikiRoutes); await subApp.register(portalContractRoutes); + await subApp.register(mcpRoutes); },{prefix: "/api"}) diff --git a/backend/src/mcp/authz.ts b/backend/src/mcp/authz.ts new file mode 100644 index 0000000..bf3b71c --- /dev/null +++ b/backend/src/mcp/authz.ts @@ -0,0 +1,88 @@ +import { FastifyInstance, FastifyRequest } from "fastify" +import { and, eq, or, isNull, inArray } from "drizzle-orm" +import { + authRoles, + authRolePermissions, + authUserRoles, +} from "../../db/schema" +import { McpContext, McpTool } from "./types" + +export async function loadTenantPermissions( + server: FastifyInstance, + userId: string, + tenantId: number +) { + const roleRows = await server.db + .select({ + roleId: authUserRoles.role_id, + }) + .from(authUserRoles) + .innerJoin( + authRoles, + and( + eq(authRoles.id, authUserRoles.role_id), + or(isNull(authRoles.tenant_id), eq(authRoles.tenant_id, tenantId)) + ) + ) + .where( + and( + eq(authUserRoles.user_id, userId), + eq(authUserRoles.tenant_id, tenantId) + ) + ) + + const roleIds = Array.from(new Set(roleRows.map((row) => row.roleId))) + + if (roleIds.length === 0) return [] + + const permissionRows = await server.db + .select({ + permission: authRolePermissions.permission, + }) + .from(authRolePermissions) + .where(inArray(authRolePermissions.role_id, roleIds)) + + return Array.from(new Set(permissionRows.map((row) => row.permission))) +} + +export async function createMcpContext( + server: FastifyInstance, + request: FastifyRequest +): Promise { + const user = request.user + + if (!user?.user_id) { + throw Object.assign(new Error("Authentication required"), { statusCode: 401 }) + } + + if (!user.tenant_id) { + throw Object.assign(new Error("MCP benötigt einen aktiven Mandanten"), { statusCode: 403 }) + } + + const permissions = await loadTenantPermissions(server, user.user_id, user.tenant_id) + + return { + server, + request, + tenantId: user.tenant_id, + userId: user.user_id, + isAdmin: Boolean(user.is_admin), + permissions, + } +} + +export function assertToolPermission(context: McpContext, tool: McpTool) { + if (context.isAdmin) return + + const allowed = tool.requiredPermissions.every((permission) => + context.permissions.includes(permission) + ) + + if (!allowed) { + throw Object.assign( + new Error(`Fehlende Berechtigung für ${tool.name}: ${tool.requiredPermissions.join(", ")}`), + { statusCode: 403 } + ) + } +} + diff --git a/backend/src/mcp/registry.ts b/backend/src/mcp/registry.ts new file mode 100644 index 0000000..b2defcd --- /dev/null +++ b/backend/src/mcp/registry.ts @@ -0,0 +1,10 @@ +import { accountingTools } from "./tools/accounting" +import { organisationTools } from "./tools/organisation" + +export const mcpTools = [ + ...accountingTools, + ...organisationTools, +] + +export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool])) + diff --git a/backend/src/mcp/result.ts b/backend/src/mcp/result.ts new file mode 100644 index 0000000..f3bfab7 --- /dev/null +++ b/backend/src/mcp/result.ts @@ -0,0 +1,36 @@ +import { McpToolResult } from "./types" + +export function asToolResult(payload: unknown): McpToolResult { + const structuredContent = + payload && typeof payload === "object" && !Array.isArray(payload) + ? payload as Record + : { result: payload } + + return { + content: [ + { + type: "text", + text: JSON.stringify(payload, null, 2), + }, + ], + structuredContent, + } +} + +export function asToolError(error: unknown): McpToolResult { + const message = error instanceof Error ? error.message : "Unbekannter Fehler" + + return { + content: [ + { + type: "text", + text: message, + }, + ], + isError: true, + structuredContent: { + error: message, + }, + } +} + diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts new file mode 100644 index 0000000..f2c9e17 --- /dev/null +++ b/backend/src/mcp/tools/accounting.ts @@ -0,0 +1,194 @@ +import { and, desc, eq, ilike, or } from "drizzle-orm" +import { + accounts, + bankstatements, + incominginvoices, + statementallocations, +} from "../../../db/schema" +import { McpTool } from "../types" + +const limitFromArgs = (args: Record, fallback = 25) => { + const raw = Number(args.limit ?? fallback) + if (!Number.isFinite(raw)) return fallback + return Math.min(Math.max(Math.trunc(raw), 1), 100) +} + +const stringArg = (args: Record, key: string) => { + const value = args[key] + return typeof value === "string" && value.trim() ? value.trim() : null +} + +const numberArg = (args: Record, key: string) => { + const value = Number(args[key]) + return Number.isFinite(value) ? value : null +} + +export const accountingTools: McpTool[] = [ + { + name: "accounting.accounts.search", + title: "Konten suchen", + description: "Sucht Sachkonten im aktiven Kontenrahmen des Mandanten.", + requiredPermissions: ["accounting.accounts.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const tenantRows = await context.server.db.query.tenants.findMany({ + where: (tenant, { eq }) => eq(tenant.id, context.tenantId), + columns: { + accountChart: true, + }, + limit: 1, + }) + const accountChart = tenantRows[0]?.accountChart || "skr03" + const query = stringArg(args, "query") + const limit = limitFromArgs(args) + + const whereCond = query + ? and( + eq(accounts.accountChart, accountChart), + or( + ilike(accounts.number, `%${query}%`), + ilike(accounts.label, `%${query}%`), + ilike(accounts.description, `%${query}%`) + ) + ) + : eq(accounts.accountChart, accountChart) + + const rows = await context.server.db + .select() + .from(accounts) + .where(whereCond) + .orderBy(accounts.number) + .limit(limit) + + return { accountChart, rows } + }, + }, + { + name: "accounting.incoming_invoices.list", + title: "Eingangsrechnungen auflisten", + description: "Listet Eingangsrechnungen des aktiven Mandanten.", + requiredPermissions: ["accounting.incoming_invoices.read"], + inputSchema: { + type: "object", + properties: { + state: { type: "string", description: "Optionaler Statusfilter." }, + paid: { type: "boolean", description: "Optionaler Zahlungsstatus." }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(incominginvoices.tenant, context.tenantId)] + const state = stringArg(args, "state") + + if (state) conditions.push(eq(incominginvoices.state, state)) + if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid)) + if (args.includeArchived !== true) conditions.push(eq(incominginvoices.archived, false)) + + const rows = await context.server.db + .select() + .from(incominginvoices) + .where(and(...conditions)) + .orderBy(desc(incominginvoices.createdAt)) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "accounting.incoming_invoices.get", + title: "Eingangsrechnung laden", + description: "Lädt eine Eingangsrechnung des aktiven Mandanten anhand ihrer ID.", + requiredPermissions: ["accounting.incoming_invoices.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "number" }, + }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const rows = await context.server.db + .select() + .from(incominginvoices) + .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) + .limit(1) + + if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden") + return { invoice: rows[0] } + }, + }, + { + name: "accounting.bank_statements.list", + title: "Bankumsätze auflisten", + description: "Listet Bankumsätze des aktiven Mandanten.", + requiredPermissions: ["accounting.bank.read"], + inputSchema: { + type: "object", + properties: { + account: { type: "number", description: "Optionale Bankkonto-ID." }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(bankstatements.tenant, context.tenantId)] + const account = numberArg(args, "account") + + if (account) conditions.push(eq(bankstatements.account, account)) + if (args.includeArchived !== true) conditions.push(eq(bankstatements.archived, false)) + + const rows = await context.server.db + .select() + .from(bankstatements) + .where(and(...conditions)) + .orderBy(desc(bankstatements.date), desc(bankstatements.id)) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "accounting.statement_allocations.list", + title: "Buchungszuordnungen auflisten", + description: "Listet Buchungszuordnungen des aktiven Mandanten.", + requiredPermissions: ["accounting.statement_allocations.read"], + inputSchema: { + type: "object", + properties: { + bankstatement: { type: "number" }, + incominginvoice: { type: "number" }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(statementallocations.tenant, context.tenantId)] + const bankstatement = numberArg(args, "bankstatement") + const incominginvoice = numberArg(args, "incominginvoice") + + if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement)) + if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice)) + if (args.includeArchived !== true) conditions.push(eq(statementallocations.archived, false)) + + const rows = await context.server.db + .select() + .from(statementallocations) + .where(and(...conditions)) + .orderBy(desc(statementallocations.created_at)) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, +] + diff --git a/backend/src/mcp/tools/organisation.ts b/backend/src/mcp/tools/organisation.ts new file mode 100644 index 0000000..18906f6 --- /dev/null +++ b/backend/src/mcp/tools/organisation.ts @@ -0,0 +1,183 @@ +import { and, desc, eq, ilike, or } from "drizzle-orm" +import { tasks } from "../../../db/schema" +import { McpTool } from "../types" + +const limitFromArgs = (args: Record, fallback = 25) => { + const raw = Number(args.limit ?? fallback) + if (!Number.isFinite(raw)) return fallback + return Math.min(Math.max(Math.trunc(raw), 1), 100) +} + +const stringArg = (args: Record, key: string) => { + const value = args[key] + return typeof value === "string" && value.trim() ? value.trim() : null +} + +const numberArg = (args: Record, key: string) => { + const value = Number(args[key]) + return Number.isFinite(value) ? value : null +} + +export const organisationTools: McpTool[] = [ + { + name: "organisation.tasks.list", + title: "Aufgaben auflisten", + description: "Listet Aufgaben des aktiven Mandanten mit optionalen Filtern.", + requiredPermissions: ["organisation.tasks.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." }, + project: { type: "number" }, + customer: { type: "number" }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(tasks.tenant, context.tenantId)] + const query = stringArg(args, "query") + const project = numberArg(args, "project") + const customer = numberArg(args, "customer") + + if (query) { + conditions.push(or( + ilike(tasks.name, `%${query}%`), + ilike(tasks.description, `%${query}%`), + ilike(tasks.categorie, `%${query}%`) + )) + } + if (project) conditions.push(eq(tasks.project, project)) + if (customer) conditions.push(eq(tasks.customer, customer)) + if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false)) + + const rows = await context.server.db + .select() + .from(tasks) + .where(and(...conditions)) + .orderBy(desc(tasks.createdAt)) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "organisation.tasks.get", + title: "Aufgabe laden", + description: "Lädt eine Aufgabe des aktiven Mandanten anhand ihrer ID.", + requiredPermissions: ["organisation.tasks.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "number" }, + }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const rows = await context.server.db + .select() + .from(tasks) + .where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId))) + .limit(1) + + if (!rows[0]) throw new Error("Aufgabe nicht gefunden") + return { task: rows[0] } + }, + }, + { + name: "organisation.tasks.create", + title: "Aufgabe erstellen", + description: "Erstellt eine neue Aufgabe im aktiven Mandanten.", + requiredPermissions: ["organisation.tasks.write"], + inputSchema: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + description: { type: "string" }, + categorie: { type: "string" }, + project: { type: "number" }, + plant: { type: "number" }, + customer: { type: "number" }, + userId: { type: "string" }, + profiles: { type: "array", items: { type: "string" } }, + }, + }, + async handler(context, args) { + const name = stringArg(args, "name") + if (!name) throw new Error("name ist erforderlich") + + const [created] = await context.server.db + .insert(tasks) + .values({ + name, + description: stringArg(args, "description"), + categorie: stringArg(args, "categorie"), + tenant: context.tenantId, + userId: stringArg(args, "userId") || context.userId, + project: numberArg(args, "project"), + plant: numberArg(args, "plant"), + customer: numberArg(args, "customer"), + profiles: Array.isArray(args.profiles) ? args.profiles : [], + updatedAt: new Date(), + updatedBy: context.userId, + }) + .returning() + + return { task: created } + }, + }, + { + name: "organisation.tasks.update", + title: "Aufgabe aktualisieren", + description: "Aktualisiert Felder einer Aufgabe im aktiven Mandanten.", + requiredPermissions: ["organisation.tasks.write"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "number" }, + name: { type: "string" }, + description: { type: "string" }, + categorie: { type: "string" }, + project: { type: "number" }, + plant: { type: "number" }, + customer: { type: "number" }, + userId: { type: "string" }, + profiles: { type: "array", items: { type: "string" } }, + archived: { type: "boolean" }, + }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const update: Record = { + updatedAt: new Date(), + updatedBy: context.userId, + } + + for (const key of ["name", "description", "categorie", "userId"] as const) { + if (args[key] !== undefined) update[key] = stringArg(args, key) + } + for (const key of ["project", "plant", "customer"] as const) { + if (args[key] !== undefined) update[key] = numberArg(args, key) + } + if (Array.isArray(args.profiles)) update.profiles = args.profiles + if (typeof args.archived === "boolean") update.archived = args.archived + + const [updated] = await context.server.db + .update(tasks) + .set(update) + .where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId))) + .returning() + + if (!updated) throw new Error("Aufgabe nicht gefunden") + return { task: updated } + }, + }, +] + diff --git a/backend/src/mcp/types.ts b/backend/src/mcp/types.ts new file mode 100644 index 0000000..c4430de --- /dev/null +++ b/backend/src/mcp/types.ts @@ -0,0 +1,36 @@ +import { FastifyInstance, FastifyRequest } from "fastify" + +export type McpContext = { + server: FastifyInstance + request: FastifyRequest + tenantId: number + userId: string + isAdmin: boolean + permissions: string[] +} + +export type McpToolResult = { + content: Array<{ + type: "text" + text: string + }> + structuredContent?: Record + isError?: boolean +} + +export type McpTool = { + name: string + title: string + description: string + requiredPermissions: string[] + inputSchema: Record + handler: (context: McpContext, args: Record) => Promise +} + +export type JsonRpcRequest = { + jsonrpc?: string + id?: string | number | null + method?: string + params?: any +} + diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index 1f8e204..8f05bcb 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -9,7 +9,7 @@ import { authUsers, } from "../../db/schema" -import { eq, and } from "drizzle-orm" +import { eq, and, inArray } from "drizzle-orm" export default fp(async (server: FastifyInstance) => { server.addHook("preHandler", async (req, reply) => { @@ -63,10 +63,12 @@ export default fp(async (server: FastifyInstance) => { const userId = req.user.user_id // -------------------------------------------------------- - // 3️⃣ Rolle des Nutzers im Tenant holen + // 3️⃣ Rollen des Nutzers im Tenant holen // -------------------------------------------------------- const roleRows = await server.db - .select() + .select({ + role_id: authUserRoles.role_id, + }) .from(authUserRoles) .where( and( @@ -74,7 +76,6 @@ export default fp(async (server: FastifyInstance) => { eq(authUserRoles.tenant_id, tenantId) ) ) - .limit(1) if (roleRows.length === 0) { if (req.user.is_admin) { @@ -89,22 +90,22 @@ export default fp(async (server: FastifyInstance) => { .send({ error: "No role assigned for this tenant" }) } - const roleId = roleRows[0].role_id + const roleIds = Array.from(new Set(roleRows.map((role) => role.role_id))) // -------------------------------------------------------- - // 4️⃣ Berechtigungen der Rolle laden + // 4️⃣ Berechtigungen der Rollen laden // -------------------------------------------------------- const permissionRows = await server.db .select() .from(authRolePermissions) - .where(eq(authRolePermissions.role_id, roleId)) + .where(inArray(authRolePermissions.role_id, roleIds)) - const permissions = permissionRows.map((p) => p.permission) + const permissions = Array.from(new Set(permissionRows.map((p) => p.permission))) // -------------------------------------------------------- // 5️⃣ An Request hängen für spätere Nutzung // -------------------------------------------------------- - req.role = roleId + req.role = roleIds[0] req.permissions = permissions req.hasPermission = (perm: string) => permissions.includes(perm) diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts new file mode 100644 index 0000000..94f06e1 --- /dev/null +++ b/backend/src/routes/mcp.ts @@ -0,0 +1,144 @@ +import { FastifyInstance } from "fastify" +import { assertToolPermission, createMcpContext } from "../mcp/authz" +import { mcpToolMap, mcpTools } from "../mcp/registry" +import { asToolError, asToolResult } from "../mcp/result" +import { JsonRpcRequest } from "../mcp/types" + +const SUPPORTED_PROTOCOL_VERSIONS = [ + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05", +] + +function jsonRpcResult(id: JsonRpcRequest["id"], result: unknown) { + return { + jsonrpc: "2.0", + id, + result, + } +} + +function jsonRpcError(id: JsonRpcRequest["id"], code: number, message: string) { + return { + jsonrpc: "2.0", + id: id ?? null, + error: { + code, + message, + }, + } +} + +function selectProtocolVersion(clientVersion?: string) { + if (clientVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)) { + return clientVersion + } + + return SUPPORTED_PROTOCOL_VERSIONS[0] +} + +export default async function mcpRoutes(server: FastifyInstance) { + server.post("/mcp", async (req, reply) => { + const body = req.body as JsonRpcRequest | JsonRpcRequest[] + const requests = Array.isArray(body) ? body : [body] + const responses = [] + + for (const request of requests) { + const id = request?.id + + if (!request || request.jsonrpc !== "2.0" || !request.method) { + responses.push(jsonRpcError(id, -32600, "Invalid JSON-RPC request")) + continue + } + + if (request.method === "notifications/initialized") { + continue + } + + if (request.method === "initialize") { + const clientVersion = request.params?.protocolVersion + + responses.push(jsonRpcResult(id, { + protocolVersion: selectProtocolVersion(clientVersion), + capabilities: { + tools: { + listChanged: false, + }, + }, + serverInfo: { + name: "fedeo-mcp", + version: "1.0.0", + }, + instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.", + })) + continue + } + + if (request.method === "ping") { + responses.push(jsonRpcResult(id, {})) + continue + } + + if (request.method === "tools/list") { + responses.push(jsonRpcResult(id, { + tools: mcpTools.map((tool) => ({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: { + readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")), + }, + })), + })) + continue + } + + if (request.method === "tools/call") { + const toolName = request.params?.name + const tool = typeof toolName === "string" ? mcpToolMap.get(toolName) : null + + if (!tool) { + responses.push(jsonRpcError(id, -32602, `Unknown tool: ${toolName}`)) + continue + } + + try { + const context = await createMcpContext(server, req) + assertToolPermission(context, tool) + + const result = await tool.handler(context, request.params?.arguments || {}) + responses.push(jsonRpcResult(id, asToolResult(result))) + } catch (error) { + const statusCode = (error as any)?.statusCode + + if (statusCode === 401 || statusCode === 403) { + responses.push(jsonRpcError(id, statusCode === 401 ? -32001 : -32003, error instanceof Error ? error.message : "Forbidden")) + } else { + responses.push(jsonRpcResult(id, asToolError(error))) + } + } + continue + } + + responses.push(jsonRpcError(id, -32601, `Method not found: ${request.method}`)) + } + + if (responses.length === 0) { + return reply.code(204).send() + } + + return Array.isArray(body) ? responses : responses[0] + }) + + server.get("/mcp", async (_req, reply) => { + return reply.send({ + name: "fedeo-mcp", + transport: "http-json-rpc", + endpoint: "/api/mcp", + tools: mcpTools.map((tool) => tool.name), + }) + }) +} +