MCP-Server für Buchhaltung und Organisation ergänzen
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.
This commit is contained in:
@@ -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"})
|
||||
|
||||
|
||||
88
backend/src/mcp/authz.ts
Normal file
88
backend/src/mcp/authz.ts
Normal file
@@ -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<McpContext> {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
10
backend/src/mcp/registry.ts
Normal file
10
backend/src/mcp/registry.ts
Normal file
@@ -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]))
|
||||
|
||||
36
backend/src/mcp/result.ts
Normal file
36
backend/src/mcp/result.ts
Normal file
@@ -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<string, unknown>
|
||||
: { 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
194
backend/src/mcp/tools/accounting.ts
Normal file
194
backend/src/mcp/tools/accounting.ts
Normal file
@@ -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<string, unknown>, 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<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
const numberArg = (args: Record<string, unknown>, 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 }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
183
backend/src/mcp/tools/organisation.ts
Normal file
183
backend/src/mcp/tools/organisation.ts
Normal file
@@ -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<string, unknown>, 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<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
const numberArg = (args: Record<string, unknown>, 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<string, unknown> = {
|
||||
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 }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
36
backend/src/mcp/types.ts
Normal file
36
backend/src/mcp/types.ts
Normal file
@@ -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<string, unknown>
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export type McpTool = {
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
requiredPermissions: string[]
|
||||
inputSchema: Record<string, unknown>
|
||||
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
|
||||
}
|
||||
|
||||
export type JsonRpcRequest = {
|
||||
jsonrpc?: string
|
||||
id?: string | number | null
|
||||
method?: string
|
||||
params?: any
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
144
backend/src/routes/mcp.ts
Normal file
144
backend/src/routes/mcp.ts
Normal file
@@ -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),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user