import { MCP_REQUEST_MONITOR_PROJECTION } from "../modules/mcp/request-monitor-projection.js" import { humanIpsecUserBundleMetadata, readHumanIpsecUserBundleArtifact } from "../modules/mcp/ipsec-user-create-artifact.js" import { humanIpsecExistingCertificateExportMetadata, readHumanIpsecExistingCertificateExportArtifact } from "../services/ipsec-existing-certificate-export-artifact.js" import type { createIpsecExistingCertificateExportArtifactStore } from "../services/ipsec-existing-certificate-export-artifact.js" import type { FastifyPluginAsync } from "fastify" import { z } from "zod" import { randomUUID, createHash } from "node:crypto" import { mcpCatalogResponseSchema, mcpConnectionCreateSchema, mcpPageQuerySchema, mcpPolicyPreviewRequestSchema, mcpPolicyPreviewResponseSchema, mcpSettingsPatchSchema, mcpTemporaryGrantSchema } from "@mmapp/contracts/mcp" import { mcpProtectionCreateRequestSchema } from "../modules/mcp/protection-requests.js" import { env } from "../config.js" import { requireAuth } from "../plugins/auth.js" import { listMcpOperations, findMcpOperation } from "../modules/mcp/registry.js" import { evaluateMcpPolicy } from "../modules/mcp/policy.js" import { listActions } from "../modules/mcp/actions.js" import { pool } from "../db/index.js" import { hasPermission, type AuthUser } from "../lib/permissions.js" import { accessGeneration, decryptSecret, encryptSecret, McpError, randomToken, redact, tokenDigest } from "../modules/mcp/security.js" import { createMcpConnection, listMcpConnections, getMcpSettings, updateMcpSettings, getConnection, assertConnectionVisible, rotateConnectionToken, setConnectionStatus, updateMcpConnection, transaction, audit } from "../modules/mcp/store.js" import { approvePlan, loadPlan, publicPlan } from "../modules/mcp/runtime.js" import { applyPlan, invokeMcpTool } from "../modules/mcp/runtime.js" import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing" import { revalidateMcpOwner } from "../modules/mcp/store.js" import { resourceById } from "../modules/mcp/resources.js" import { registerManagementChannel } from "../modules/mcp/management.js" import { MAX_RESTORE_ARCHIVE_BYTES, uploadRestoreArchive, readRestoreReceipt, readRestoreReceiptAuditStatus } from "../modules/mcp/database-restore.js" import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js" import { readFile } from "node:fs/promises" import { checkMcpProtocol, type ProtocolProbe } from "../modules/mcp/readiness.js" import { humanBackupDownloadMetadata, readHumanBackupDownloadArtifact } from "../modules/mcp/backup-download-artifact.js" import { humanDatabaseBackupMetadata, readHumanDatabaseBackupArtifact } from "../modules/mcp/database-backup-artifact.js" import { humanWireguardExportMetadata, readHumanWireguardExportArtifact } from "../modules/mcp/wireguard-export-artifact.js" import { humanArtifactReceipts } from "../modules/mcp/human-artifact-receipts.js" import { readHumanHostFilesArtifact } from "../modules/mcp/host-files-artifact.js" import { loadLogExportArtifactMetadata } from "../modules/mcp/log-artifacts.js" import { readServerPollCapacity } from "../services/server-poll-capacity.js" import { connectProbeStorage } from "../services/server-probe-storage-connection.js" const uuid = z.string().uuid() const deviceResource = z.enum(["ethernet", "bridge", "bridge_port", "bridge_vlan", "vlan", "ipv4_address", "ipv6_address", "vrf"]) const devicePlanCommon = { connectionId: uuid, fields: z.record(z.string(), z.unknown()).default({}) } export const mcpDevicePlanBodySchema = z.discriminatedUnion("action", [ z.object({ ...devicePlanCommon, action: z.literal("create") }).strict(), z.object({ ...devicePlanCommon, action: z.literal("update"), id: z.string().min(1).max(200).optional() }).strict(), z.object({ ...devicePlanCommon, action: z.literal("delete"), id: z.string().min(1).max(200).optional() }).strict(), ]) export function mcpDevicePlanArguments(serverId: number, input: z.infer) { return { serverId, ...(input.action === "create" ? {} : { id: input.id }), fields: input.fields } } const idOf = (params: unknown) => z.object({ id: uuid }).parse(params).id const actorOf = (actor?: AuthUser) => { if (!actor) throw new McpError("UNAUTHORIZED", "Требуется вход", 401); return actor } async function pollCapacityForPortal(ownerId:string,admin:boolean){ const options={deadlineAt:Date.now()+3000} let connection:Awaited>|undefined try{ connection=await connectProbeStorage(options) return await readServerPollCapacity({query:(text,values)=>connection!.query(text,values,{timeoutMs:Math.max(1,options.deadlineAt-Date.now())})},ownerId,admin) }catch{return {status:"unavailable" as const}} finally{connection?.release()} } /** Only the delegated MCP identity failed; the human JWT/claims hooks ran outside. * Never translate OWNER_CLAIMS_REVOKED or another authentication failure. */ async function ownerDelegate(work: () => Promise): Promise { try { return await work() } catch (error) { if (error instanceof McpError && error.code === "UNAUTHORIZED" && error.statusCode === 401) throw new McpError("PERMISSION_DENIED", "Допуск MCP подключения недоступен", 403) throw error } } async function ownerPrincipal(connectionId: string, actor: AuthUser, operationId: string) { const connection = await getConnection(connectionId) if (connection.owner_id !== actor.id) throw new McpError("PERMISSION_DENIED", "Выполнение из web UI доступно владельцу подключения") const action = listActions().find(action => action.operation.id === operationId) if (!action || action.operation.requiredPermissions.some(permission => !hasPermission(actor.permissions, permission))) throw new McpError("PERMISSION_DENIED", "Текущий пользователь не имеет права выполнить операцию") // Fixed UI actions use the connection's existing access without exposing credentials. const { rows: [credential] } = await pool.query(`SELECT c.id,c.expires_at FROM mcp_credentials c WHERE c.connection_id=$1 AND c.kind='bearer' AND c.revoked_at IS NULL AND c.expires_at>now() AND NOT EXISTS(SELECT 1 FROM mcp_grants g WHERE g.payload->>'credentialId'=c.id::text) ORDER BY c.created_at DESC LIMIT 1`, [connectionId]) if (!credential) throw new McpError("PERMISSION_DENIED", "У подключения нет действующего основного допуска", 403) return { connectionId, credentialId: credential.id, credentialExpiresAt: credential.expires_at.toISOString() } } /** Human administration and fixed owner UI actions; never a generic RouterOS proxy. */ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options: { existingCertificateArtifact?: Pick, "metadata" | "read"> } = {}): FastifyPluginAsync { const existingCertificateArtifact = options.existingCertificateArtifact ?? { metadata: humanIpsecExistingCertificateExportMetadata, read: readHumanIpsecExistingCertificateExportArtifact } return async app => { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer", bodyLimit: MAX_RESTORE_ARCHIVE_BYTES }, (_request, body, done) => done(null, body)) app.setErrorHandler((error, request, reply) => { if (error instanceof z.ZodError) return reply.code(400).send({ error: "Некорректные параметры MCP", code: "INVALID_INPUT", details: error.flatten() }) if (error instanceof McpError) return reply.code(error.statusCode).send({ error: error.message, code: error.code }) request.log.error({ error: redact(error instanceof Error ? error.message : error) }, "MCP administration failed") return reply.code(503).send({ error: "Хранилище MCP или аудит недоступны", code: "AUDIT_UNAVAILABLE" }) }) app.addHook("onRequest", async (request, reply) => { if (!env.authRequired) return reply.code(503).send({ error: "Для управления MCP включите проверку входа через портал" }) // Explicit guard: these routes must stay protected if the global API hook changes. await requireAuth(request, reply) if (reply.sent) return await checkClaims(actorOf(request.authUser)) if (request.headers.origin && request.headers.origin !== env.CORS_ORIGIN) return reply.code(403).send({ error: "Недопустимый Origin" }) }) app.post("/mcp/database/archives", { bodyLimit: MAX_RESTORE_ARCHIVE_BYTES }, async request => uploadRestoreArchive(request.body as Buffer, actorOf(request.authUser))) app.get("/mcp/database/receipts/:id", async request => readRestoreReceipt(idOf(request.params), actorOf(request.authUser))) app.get("/mcp/database/receipts/:id/audit-status", async request => readRestoreReceiptAuditStatus(idOf(request.params), actorOf(request.authUser))) app.post("/mcp/database/plan", async request => { const { connectionId, archiveRef, expectedSha256 } = z.object({ connectionId: uuid, archiveRef: uuid, expectedSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict().parse(request.body) const actor = actorOf(request.authUser) return ownerDelegate(async () => invokeMcpTool(await ownerPrincipal(connectionId, actor, "database.restore"), "mm_database_restore_plan", { archiveRef, expectedSha256 })) }) app.get("/mcp/catalog", async () => mcpCatalogResponseSchema.parse({ stage: "runtime", executionAvailable: true, operations: listMcpOperations(), })) app.get("/mcp/coverage", async () => JSON.parse(await readFile(new URL("../../mcp-coverage.json", import.meta.url), "utf8"))) app.get("/mcp/management-channels", async () => ({ channels: (await pool.query("SELECT id,server_id,created_by,attestation,expires_at,verified_at,revoked_at FROM mcp_management_channels ORDER BY server_id")).rows })) app.post("/mcp/management-channels", async request => registerManagementChannel(request.body, actorOf(request.authUser))) app.delete("/mcp/management-channels/:id", async request => { const actor = actorOf(request.authUser), id = idOf(request.params) await transaction(async db => { await db.query("UPDATE mcp_management_channels SET revoked_at=now() WHERE id=$1", [id]) await audit(db, actor.id, "management.revoke", null, { channelId: id }) }); return { ok: true } }) app.post("/mcp/policy/preview", async (request, reply) => { const parsed = mcpPolicyPreviewRequestSchema.safeParse(request.body) if (!parsed.success) return reply.code(400).send({ error: "Некорректные параметры симуляции политики MCP", details: parsed.error.flatten() }) const now = new Date() return mcpPolicyPreviewResponseSchema.parse({ simulation: true, executionAvailable: false, evaluatedAt: now.toISOString(), policyVersion: parsed.data.policy.version, result: evaluateMcpPolicy(parsed.data, now), }) }) app.get("/mcp/connections", async request => ({ connections: await listMcpConnections(actorOf(request.authUser)) })) app.post("/mcp/connections", async request => { const input = mcpConnectionCreateSchema.parse(request.body) const available = new Set(listActions().map(action => action.operation.id)) if (input.scope.operationIds.some(id => !available.has(id))) throw new McpError("UNKNOWN_OPERATION", "Можно выдать только реализованные операции", 400) return createMcpConnection(input, actorOf(request.authUser)) }) app.put("/mcp/connections/:id", async request => { const input = mcpConnectionCreateSchema.parse(request.body), available = new Set(listActions().map(action => action.operation.id)) if (input.scope.operationIds.some(id => !available.has(id))) throw new McpError("UNKNOWN_OPERATION", "Можно выдать только реализованные операции", 400) return updateMcpConnection(idOf(request.params), input, actorOf(request.authUser)) }) app.post("/mcp/connections/:id/status", async request => { await setConnectionStatus(idOf(request.params), z.object({ status: z.enum(["active", "paused", "revoked"]) }).strict().parse(request.body).status, actorOf(request.authUser)); return { ok: true } }) app.post("/mcp/connections/:id/rotate", async request => rotateConnectionToken(idOf(request.params), actorOf(request.authUser))) app.post("/mcp/connections/:id/revalidate", async request => revalidateMcpOwner(idOf(request.params), actorOf(request.authUser))) app.get("/mcp/devices/catalog", async request => { const { connectionId } = z.object({ connectionId: uuid }).strict().parse(request.query), actor = actorOf(request.authUser), connection = await getConnection(connectionId) if (connection.owner_id !== actor.id) throw new McpError("PERMISSION_DENIED", "Выберите своё подключение") const servers = (await pool.query("SELECT id,name,host,enabled FROM servers WHERE id=ANY($1::bigint[]) ORDER BY name LIMIT 1000", [connection.policy.scope.serverIds])).rows const resources = deviceResource.options.map(id => { const resource = resourceById(id), json = z.toJSONSchema(resource.fields, { io: "input" }) const allowed = (action: string) => connection.policy.scope.operationIds.includes(`ros.${id}.${action}`) && hasPermission(actor.permissions, resource.permission + (action === "list" ? ":read" : ":write")) && (action === "list" || connection.policy.mode !== "observer") const fields = Object.entries(json.properties ?? {}).map(([key, property]) => { const schema = property as { type?: string; enum?: string[]; minimum?: number; maximum?: number; minLength?: number; maxLength?: number } return { key, type: schema.type === "boolean" ? "boolean" : ["number", "integer"].includes(schema.type ?? "") ? "number" : "string", ...(schema.enum ? { options: schema.enum } : {}), min: schema.minimum, max: schema.maximum, maxLength: schema.maxLength, requiredOnCreate: resource.requiredOnCreate?.includes(key) ?? false } }) return { id, title: resource.title, fields, read: allowed("list"), create: resource.create && allowed("create"), update: allowed("update"), delete: resource.remove && allowed("delete") } }).filter(resource => resource.read || resource.create || resource.update || resource.delete) return { servers, resources } }) app.get("/mcp/devices/:serverId/:resource", async request => { const params = z.object({ serverId: z.coerce.number().int().positive(), resource: deviceResource }).parse(request.params) const query = z.object({ connectionId: uuid, offset: z.coerce.number().int().min(0).max(1000000).default(0) }).strict().parse(request.query) const principal = await ownerPrincipal(query.connectionId, actorOf(request.authUser), `ros.${params.resource}.list`) return ownerDelegate(async () => invokeMcpTool(principal, `mm_${params.resource}_list`, { serverId: params.serverId, limit: 100, offset: query.offset })) }) app.post("/mcp/devices/:serverId/:resource/plan", async request => { const params = z.object({ serverId: z.coerce.number().int().positive(), resource: deviceResource }).parse(request.params) const input = mcpDevicePlanBodySchema.parse(request.body) const actor = actorOf(request.authUser), principal = await ownerPrincipal(input.connectionId, actor, `ros.${params.resource}.${input.action}`) await audit(pool, actor.id, "device.web.plan", input.connectionId, { serverId: params.serverId, resource: params.resource, action: input.action, objectId: input.action === "create" ? null : input.id ?? null }) return ownerDelegate(async () => invokeMcpTool(principal, `mm_${params.resource}_${input.action}_plan`, mcpDevicePlanArguments(params.serverId, input))) }) app.get("/mcp/settings", async () => getMcpSettings()) app.put("/mcp/settings", async request => updateMcpSettings(mcpSettingsPatchSchema.parse(request.body), actorOf(request.authUser))) app.post("/mcp/pause", async request => { await updateMcpSettings({ enabled: false }, actorOf(request.authUser)); return { ok: true } }) app.post("/mcp/stop-automation", async request => { const actor = actorOf(request.authUser) await transaction(async client => { await client.query("UPDATE mcp_settings SET enabled=false,updated_at=now() WHERE id=1") await client.query("UPDATE mcp_routing_profiles SET enabled=false,state=state || '{\"pausedReason\":\"Аварийная остановка автоматических изменений\"}'::jsonb,updated_at=now() WHERE enabled") await audit(client, actor.id, "automation.emergency-stop", null, {}) }); return { ok: true } }) app.get("/mcp/overview", async request => { const actor = actorOf(request.authUser), admin = hasPermission(actor.permissions, "mm:mcp:admin") const { rows: [summary] } = await pool.query(`SELECT (SELECT count(*)::int FROM mcp_connections WHERE ($1::boolean OR owner_id=$2) AND policy->>'status'='active') AS connections, (SELECT count(*)::int FROM mcp_requests r JOIN mcp_connections c ON c.id=r.connection_id WHERE ($1::boolean OR c.owner_id=$2) AND r.created_at>now()-interval '24 hours') AS calls, (SELECT count(*)::int FROM mcp_requests r JOIN mcp_connections c ON c.id=r.connection_id WHERE ($1::boolean OR c.owner_id=$2) AND r.created_at>now()-interval '24 hours' AND r.status IN ('error','denied')) AS errors, (SELECT count(*)::int FROM mcp_plans p JOIN mcp_connections c ON c.id=p.connection_id WHERE ($1::boolean OR c.owner_id=$2) AND p.state='awaiting-approval' AND p.expires_at>now()) AS approvals, (SELECT count(*)::int FROM mcp_operations o JOIN mcp_connections c ON c.id=o.connection_id WHERE ($1::boolean OR c.owner_id=$2) AND o.state IN ('running','pending')) AS running`, [admin, actor.id]) return { ...summary, pollStorageCapacity:await pollCapacityForPortal(actor.id,admin), enabled: (await getMcpSettings()).enabled, observedAt: new Date().toISOString() } }) for (const table of ["requests", "plans", "operations", "audit", "drift"] as const) { app.get(`/mcp/${table}`, async request => { const actor = actorOf(request.authUser), query = mcpPageQuerySchema.parse(request.query), admin = hasPermission(actor.permissions, "mm:mcp:admin") const relation = "mcp_" + table, values: unknown[] = [admin, actor.id] const owner = table === "drift" ? "EXISTS(SELECT 1 FROM mcp_operations o JOIN mcp_connections c ON c.id=o.connection_id WHERE o.id=r.operation_id AND c.owner_id=$2)" : "EXISTS(SELECT 1 FROM mcp_connections c WHERE c.id=r.connection_id AND c.owner_id=$2)" const clauses = [`($1::boolean OR ${owner})`] const bind = (value: unknown) => { values.push(value); return "$" + values.length } if (query.connectionId && table !== "drift") clauses.push(`r.connection_id=${bind(query.connectionId)}::uuid`) if (query.status && ["requests", "plans", "operations"].includes(table)) clauses.push(`r.${table === "requests" ? "status" : "state"}=${bind(query.status)}`) if (query.tool && table === "requests") clauses.push(`r.tool=${bind(query.tool)}`) if (query.userId && ["requests", "audit"].includes(table)) clauses.push(`r.actor_id=${bind(query.userId)}`) if (query.serverId && table === "requests") clauses.push(`${bind(query.serverId)}::bigint=ANY(r.server_ids)`) const dateField = table === "drift" ? "detected_at" : "created_at" if (query.from) clauses.push(`r.${dateField}>=${bind(query.from)}::timestamptz`) if (query.to) clauses.push(`r.${dateField}<=${bind(query.to)}::timestamptz`) if (query.cursor) { const valid = table === "audit" ? /^[1-9]\d{0,18}$/.test(query.cursor) : z.string().uuid().safeParse(query.cursor).success if (!valid) throw new McpError("INVALID_CURSOR", "Некорректный курсор страницы", 400) clauses.push(`(r.${dateField},r.id)<(SELECT ${dateField},id FROM ${relation} WHERE id=${bind(query.cursor)}::${table === "audit" ? "bigint" : "uuid"})`) } const count = await pool.query<{ total: number }>(`SELECT count(*)::int AS total FROM ${relation} r WHERE ${clauses.join(" AND ")}`, values) const { rows } = await pool.query(`SELECT ${table === "requests" ? MCP_REQUEST_MONITOR_PROJECTION : "r.*"} FROM ${relation} r WHERE ${clauses.join(" AND ")} ORDER BY r.${dateField} DESC,r.id DESC LIMIT ${bind(query.limit + 1)}`, values) const entries = rows.slice(0, query.limit) const receipts = table === "operations" ? await humanArtifactReceipts(entries, actor, { query: (sql, values) => pool.query(sql, values), authorize: (id, actor, kind) => kind === "ipsec-existing-certificate-bundle" ? existingCertificateArtifact.metadata(id, actor, checkClaims) : kind === "ipsec-user-bundle" ? humanIpsecUserBundleMetadata(id, actor, checkClaims) : kind === "wireguard-export" ? humanWireguardExportMetadata(id, actor, checkClaims) : kind === "database-backup" ? humanDatabaseBackupMetadata(id, actor, checkClaims) : humanBackupDownloadMetadata(id, actor, checkClaims), checkClaims }) : new Map() return { items: table === "plans" ? await Promise.all(entries.map(row => publicPlan(row as Awaited>))) : entries.map(row => table === "operations" ? { ...redact(row) as Record, artifactReceipts: (receipts.get(row.id) ?? []).map((receipt: { artifactId: string }) => ({ ...receipt, downloadPath: `/api/mcp/artifacts/${receipt.artifactId}` })) } : redact(row)), total: count.rows[0].total, nextCursor: rows.length > query.limit ? String(entries.at(-1)?.id) : null } }) } app.get("/mcp/plans/:id", async request => { const plan = await loadPlan(idOf(request.params)); assertConnectionVisible(await getConnection(plan.connection_id), actorOf(request.authUser)); return publicPlan(plan) }) app.post("/mcp/plans/:id/decision", async request => { await approvePlan(idOf(request.params), actorOf(request.authUser), z.object({ approve: z.boolean() }).strict().parse(request.body).approve); return { ok: true } }) for (const action of ["recover", "reconcile"] as const) app.post(`/mcp/operations/:id/${action}`, async request => { const id = idOf(request.params), actor = actorOf(request.authUser) const { rows: [operation] } = await pool.query("SELECT o.connection_id,p.operation_id FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1", [id]) if (!operation) throw new McpError("NOT_FOUND", "Операция не найдена", 404) const globalReconcile = action === "reconcile" && findMcpOperation(operation.operation_id)?.scope === "global" const principal = await ownerPrincipal(operation.connection_id, actor, action === "recover" ? "operations.recover" : globalReconcile ? "operations.global.reconcile" : "operations.reconcile") return ownerDelegate(async () => invokeMcpTool(principal, action === "recover" ? "mm_operation_recovery_plan" : globalReconcile ? "mm_global_operation_reconcile" : "mm_operation_reconcile", { operationId: id })) }) app.post("/mcp/plans/:id/apply", async request => { const plan = await loadPlan(idOf(request.params)), actor = actorOf(request.authUser) const principal = await ownerPrincipal(plan.connection_id, actor, plan.operation_id) await audit(pool, actor.id, "plan.web.apply", plan.connection_id, { planId: plan.id }) return ownerDelegate(async () => applyPlan(principal, plan.id, `web:${plan.id}`)) }) app.get("/mcp/protections", async request => { const actor = actorOf(request.authUser) return { protections: (await pool.query(`SELECT p.* FROM mcp_protections p WHERE $1::boolean OR p.created_by=$2 OR EXISTS( SELECT 1 FROM mcp_connections c WHERE c.owner_id=$2 AND c.policy->'scope'->'serverIds' @> jsonb_build_array((p.object_ref->>'serverId')::bigint)) ORDER BY p.created_at DESC LIMIT 1000`, [hasPermission(actor.permissions, "mm:mcp:admin"), actor.id])).rows } }) app.post("/mcp/protections", async request => { const input = mcpProtectionCreateRequestSchema.parse(request.body), actor = actorOf(request.authUser), id = randomUUID() await transaction(async client => { await client.query("INSERT INTO mcp_protections(id,object_ref,reason,created_by) VALUES($1,$2::jsonb,$3,$4)", [id, JSON.stringify(input.object), input.reason, actor.id]); await audit(client, actor.id, "protection.create", null, input) }) return { id } }) app.delete("/mcp/protections/:id", async request => { const id = idOf(request.params); await transaction(async client => { await client.query("DELETE FROM mcp_protections WHERE id=$1", [id]); await audit(client, actorOf(request.authUser).id, "protection.delete", null, { id }) }); return { ok: true } }) app.post("/mcp/connections/:id/grants", async request => { const connectionId = idOf(request.params) const input = mcpTemporaryGrantSchema.parse(request.body), connection = await getConnection(connectionId), actor = actorOf(request.authUser) assertConnectionVisible(connection, actor) if (Date.parse(input.expiresAt) <= Date.now() || connection.policy.status !== "active" || connection.generation !== await accessGeneration()) throw new McpError("GRANT_INACTIVE", "Подключение или временный доступ недействительны", 400) if (input.scope.operationIds.some(id => !connection.policy.scope.operationIds.includes(id)) || input.scope.serverIds.some(id => !connection.policy.scope.serverIds.includes(id)) || input.scope.allowGlobal && !connection.policy.scope.allowGlobal || Date.parse(input.expiresAt) > Math.min(Date.parse(connection.policy.expiresAt), connection.owner_valid_until.getTime())) throw new McpError("PERMISSION_DENIED", "Временный доступ не может расширять основной доступ и срок подтверждённых прав владельца") const credentialId = randomUUID(), token = randomToken("bearer") await transaction(async client => { await client.query("INSERT INTO mcp_credentials(id,connection_id,digest,kind,expires_at) VALUES($1,$2,$3,'bearer',$4)", [credentialId, connectionId, tokenDigest(token), input.expiresAt]) await client.query("INSERT INTO mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)", [randomUUID(), connectionId, JSON.stringify({ ...input, credentialId })]) await audit(client, actor.id, "grant.create", connectionId, input) }) return { token, expiresAt: input.expiresAt } }) app.get("/mcp/connections/:id/grants", async request => { const connection = await getConnection(idOf(request.params)); assertConnectionVisible(connection, actorOf(request.authUser)) return { grants: (await pool.query("SELECT id,payload-'credentialId' AS grant,revoked_at FROM mcp_grants WHERE connection_id=$1 ORDER BY payload->>'expiresAt' DESC LIMIT 200", [connection.id])).rows } }) app.delete("/mcp/grants/:id", async request => { const id = idOf(request.params), actor = actorOf(request.authUser) await transaction(async client => { const grant = (await client.query("SELECT * FROM mcp_grants WHERE id=$1 FOR UPDATE", [id])).rows[0] if (!grant) throw new McpError("NOT_FOUND", "Допуск не найден", 404) assertConnectionVisible(await getConnection(grant.connection_id, client), actor) await client.query("UPDATE mcp_grants SET revoked_at=now() WHERE id=$1", [id]) await client.query("UPDATE mcp_credentials SET revoked_at=now() WHERE id=$1", [grant.payload.credentialId]) await audit(client, actor.id, "grant.revoke", grant.connection_id, { grantId: id }) }) return { ok: true } }) app.post("/mcp/connections/:id/check", async request => { const connection = await getConnection(idOf(request.params)); assertConnectionVisible(connection, actorOf(request.authUser)) const actions = listActions(), checks = { enabled: (await getMcpSettings()).enabled, generation: connection.generation === await accessGeneration(), active: connection.policy.status === "active", expires: Date.parse(connection.policy.expiresAt) > Date.now(), ownerPermissionsFresh: connection.owner_valid_until.getTime() > Date.now(), operations: connection.policy.scope.operationIds.every(id => actions.some(action => action.operation.id === id)), permissions: actions.filter(action => connection.policy.scope.operationIds.includes(action.operation.id)).every(action => action.operation.requiredPermissions.every(permission => hasPermission(connection.owner_permissions, permission) && hasPermission(connection.policy.permissions, permission))), credentials: (await pool.query("SELECT 1 FROM mcp_credentials WHERE connection_id=$1 AND revoked_at IS NULL AND expires_at>now() AND kind IN ('bearer','access') LIMIT 1", [connection.id])).rows.length > 0, } const protocolProbe: ProtocolProbe = Object.values(checks).every(Boolean) ? await checkMcpProtocol(connection.id, actorOf(request.authUser)) : { status: "skipped", reason: "PRECHECK_FAILED", elapsedMs: 0, credentialType: "temporary-no-operations", clientCredentialsVerified: false } const result = { ...checks, protocolHandshake: protocolProbe.status === "passed" } await audit(pool, actorOf(request.authUser).id, "connection.check", connection.id, { checks: result, protocolProbe }) return { ok: Object.values(result).every(Boolean), checks: result, protocolProbe, checkedAt: new Date().toISOString(), transport: "streamable-http", endpointPath: "/mcp" } }) app.get("/mcp/routing-profiles", async request => { const actor = actorOf(request.authUser) return { profiles: (await pool.query("SELECT p.id,p.connection_id,p.version,p.config,p.state,p.enabled,p.grant_expires_at,p.updated_at FROM mcp_routing_profiles p JOIN mcp_connections c ON c.id=p.connection_id WHERE ($1::boolean OR c.owner_id=$2) ORDER BY p.updated_at DESC LIMIT 200", [hasPermission(actor.permissions, "mm:mcp:admin"), actor.id])).rows } }) app.post("/mcp/routing-profiles/:id/pause", async request => { const id = idOf(request.params), actor = actorOf(request.authUser) await transaction(async client => { const profile = (await client.query("SELECT connection_id FROM mcp_routing_profiles WHERE id=$1 FOR UPDATE", [id])).rows[0] if (!profile) throw new McpError("NOT_FOUND", "Профиль не найден", 404) assertConnectionVisible(await getConnection(profile.connection_id, client), actor) await client.query("UPDATE mcp_routing_profiles SET enabled=false,state=state || '{\"pausedReason\":\"Остановлен администратором\"}'::jsonb WHERE id=$1", [id]) await audit(client, actor.id, "controller.pause", profile.connection_id, { profileId: id }) }) return { ok: true } }) app.post("/mcp/routing-profiles/:id/check", async request => { const id = idOf(request.params), actor = actorOf(request.authUser) const { candidateId, proofPurpose } = z.object({ candidateId: z.string().min(1).max(100), proofPurpose: z.enum(["community", "control-path"]).default("community") }).strict().parse(request.body) const { rows: [row] } = await pool.query("SELECT connection_id,config FROM mcp_routing_profiles WHERE id=$1", [id]) if (!row) throw new McpError("NOT_FOUND", "Профиль не найден", 404) const principal = await ownerPrincipal(row.connection_id, actor, "routing.path.verify") return ownerDelegate(async () => invokeMcpTool(principal, "mm_route_path_verify", { profile: routeAiProfileSchema.parse(row.config), candidateId, proofPurpose })) }) app.post("/mcp/routing-profiles/plan", async request => { const input = z.object({ connectionId: uuid, profileId: uuid.optional(), profile: routeAiProfileSchema, enabled: z.boolean(), expiresAt: z.string().datetime({ offset: true }) }).strict().parse(request.body) const actor = actorOf(request.authUser), principal = await ownerPrincipal(input.connectionId, actor, "routing.profiles.save") if (input.enabled && !hasPermission(actor.permissions, "mm:filters:write")) throw new McpError("PERMISSION_DENIED", "Контроллер Community требует право изменения фильтров") const { connectionId: _connection, ...payload } = input await audit(pool, actor.id, "controller.web.plan", input.connectionId, { profileId: input.profileId ?? null }) return ownerDelegate(async () => invokeMcpTool(principal, "mm_route_profile_save_plan", payload)) }) app.post("/mcp/secrets", async request => { const input = z.object({ purpose: z.string().min(1).max(512), serverId: z.number().int().positive().optional(), value: z.string().min(1).max(1000000) }).strict().parse(request.body), id = randomUUID(), actor = actorOf(request.authUser) await transaction(async client => { await client.query("INSERT INTO mcp_secrets(id,purpose,server_id,ciphertext,created_by) VALUES($1,$2,$3,$4,$5)", [id, input.purpose, input.serverId ?? null, await encryptSecret(input.value), actor.id]); await audit(client, actor.id, "secret.create", null, { secretRef: id, purpose: input.purpose, serverId: input.serverId }) }) return { secretRef: id } }) app.get("/mcp/artifacts/:id", async (request, reply) => { // The portal owner is authenticated by onRequest. A sealed artifact's // delegated credential can expire or be revoked without ending that login. const readForOwner = async (read: () => Promise): Promise => { try { return await read() } catch (error) { if (error instanceof McpError && error.code === "UNAUTHORIZED" && error.statusCode === 401) throw new McpError("PERMISSION_DENIED", "Доступ к файлу отозван", 403) throw error } } const { rows: [artifact] } = await pool.query("SELECT id,connection_id,filename,media_type,CASE WHEN octet_length(access_scope::text)<=32768 THEN access_scope ELSE NULL END AS access_scope FROM mcp_artifacts WHERE id=$1 AND expires_at>now() AND octet_length(filename)<=1024 AND octet_length(media_type)<=200", [idOf(request.params)]) if (!artifact) throw new McpError("NOT_FOUND", "Файл отсутствует или срок истёк", 404) if(artifact.access_scope?.kind==="backup-download" || artifact.access_scope?.kind==="database-backup" || artifact.access_scope?.kind==="wireguard-export" || artifact.access_scope?.kind==="ipsec-user-bundle" || artifact.access_scope?.kind==="ipsec-existing-certificate-bundle"){ const cancellation=new AbortController(),abort=()=>{if(!reply.raw.writableFinished)cancellation.abort()} request.raw.once("aborted",abort);reply.raw.once("close",abort) try { const read = artifact.access_scope.kind === "ipsec-existing-certificate-bundle" ? existingCertificateArtifact.read : artifact.access_scope.kind === "ipsec-user-bundle" ? readHumanIpsecUserBundleArtifact : artifact.access_scope.kind === "wireguard-export" ? readHumanWireguardExportArtifact : artifact.access_scope.kind === "database-backup" ? readHumanDatabaseBackupArtifact : readHumanBackupDownloadArtifact const file=await readForOwner(() => read(artifact.id,actorOf(request.authUser),checkClaims,cancellation.signal)) return reply.type(file.mediaType).header("Cache-Control","private, no-store").header("Content-Disposition",`attachment; filename="${file.filename}"`).send(file.content) } finally {request.raw.off("aborted",abort);reply.raw.off("close",abort)} } if(artifact.access_scope?.kind==="netflow-host-files"){ const file=await readForOwner(() => readHumanHostFilesArtifact(artifact.id,actorOf(request.authUser))) return reply.type(file.mediaType).header("Content-Disposition",`attachment; filename="${file.filename.replace(/[^a-zA-Z0-9._-]/g,"_")}"`).send(file.content) } const connection = await getConnection(artifact.connection_id); assertConnectionVisible(connection, actorOf(request.authUser)) if (connection.generation !== await accessGeneration() || connection.policy.status !== "active") throw new McpError("PERMISSION_DENIED", "Доступ к файлу отозван") const metadata=await loadLogExportArtifactMetadata(artifact.id,connection.id) if(!hasPermission(actorOf(request.authUser).permissions,"mm:servers:read") || !hasPermission(connection.policy.permissions,"mm:servers:read") || metadata.access_scope.policyVersion!==connection.policy.version || metadata.access_scope.generation!==await accessGeneration() || metadata.access_scope.serverIds.some(id=>!connection.policy.scope.serverIds.includes(id))) throw new McpError("PERMISSION_DENIED","Доступ к области выгрузки отозван") const payload=(await pool.query("SELECT ciphertext FROM mcp_artifacts WHERE id=$1 AND connection_id=$2 AND access_scope->>'proof'=$3 AND expires_at>now() AND octet_length(ciphertext)<=12582912",[artifact.id,artifact.connection_id,metadata.access_scope.proof])).rows[0] if(!payload)throw new McpError("NOT_FOUND","Файл отсутствует или срок истёк",404) if(createHash("sha256").update(payload.ciphertext).digest("hex")!==metadata.access_scope.encryptedSha256)throw new McpError("ARTIFACT_INVALID","Зашифрованная выгрузка изменилась",409) const content=await decryptSecret(payload.ciphertext) if(Buffer.byteLength(content)!==metadata.access_scope.bytes || createHash("sha256").update(content).digest("hex")!==metadata.access_scope.sha256)throw new McpError("ARTIFACT_INVALID","Размер или SHA-256 выгрузки не совпал",409) await checkClaims(actorOf(request.authUser)) const fresh=await getConnection(connection.id) if(fresh.generation!==await accessGeneration() || fresh.policy.status!=="active" || fresh.policy.version!==metadata.access_scope.policyVersion || !hasPermission(fresh.policy.permissions,"mm:servers:read") || metadata.access_scope.serverIds.some(id=>!fresh.policy.scope.serverIds.includes(id)))throw new McpError("PERMISSION_DENIED","Доступ к файлу отозван") return reply.type(artifact.media_type).header("Content-Disposition", `attachment; filename="${artifact.filename.replace(/[^a-zA-Z0-9._-]/g, "_")}"`).send(content) }) } } export default createMcpRoutes()