Files
MikrotikManager/backend/src/routes/mcp.ts
T

385 lines
38 KiB
TypeScript

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<typeof mcpDevicePlanBodySchema>) {
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<ReturnType<typeof connectProbeStorage>>|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<T>(work: () => Promise<T>): Promise<T> {
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<ReturnType<typeof createIpsecExistingCertificateExportArtifactStore>, "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<ReturnType<typeof loadPlan>>))) : entries.map(row => table === "operations" ? { ...redact(row) as Record<string, unknown>, 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 <T>(read: () => Promise<T>): Promise<T> => {
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()