Files
MikrotikManager/backend/src/modules/mcp/application-actions.ts
T

283 lines
35 KiB
TypeScript

import { z } from "zod"
import { geoipSettingsPatchSchema } from "@mmapp/contracts/geoip"
import { pingProbeWriteSchema, speedProbeWriteSchema, type PingProbeWrite } from "@mmapp/contracts"
import { serverCreateSchema, serverUpdateSchema } from "@mmapp/contracts/servers"
import { putRulesSchema } from "@mmapp/contracts/alerts"
import { statisticsQuerySchema, statisticsPivotQuerySchema } from "@mmapp/contracts/statistics"
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
import { readBackupJob } from "../../services/backup-job-store.js"
import { projectScopedBackupJob } from "../../services/backup-job-read.js"
import { registerBackupStorageActions } from "./backup-storage-actions.js"
import { pool } from "../../db/index.js"
import { createServer, updateServer, deleteServer, listServerSnapshots } from "../servers/service/servers-service.js"
import { resolveRosSrcIpv4 } from "../../utils/ros-src-address.js"
import { getServerRowById } from "../servers/repository/servers-repository.js"
import { getAlertsOverview, replaceAlertsConfig } from "../../services/alerts-service.js"
import { getSchedulerStatus, JOB_KEYS, listSchedulerRuns, refreshScheduler, runSchedulerJobNow } from "../../services/scheduler.js"
import { getBackupById, getBackupScheduleSettings, getBackupStorageSettings, runBackupForServer, restoreBackupToDevice, deleteBackupRecord, updateBackupScheduleSettings, testBackupStorageConnection, syncBackupsFromS3 } from "../../services/backup-service.js"
import { getCertificateRenewSettings, updateCertificateRenewSettings, getAcmeSettingsPublic } from "../../services/certificates-service.js"
import { getAlertsOutboxOverview } from "../../services/alert-engine/outbox.js"
import { readEvents } from "../events/service/events-service.js"
import { buildFlowAnalytics, getFlowMonthly } from "../../services/traffic-flow-analytics.js"
import { getTrafficSettings, updateTrafficSettings } from "../../services/traffic-collector.js"
import { readUptimeSettings, updateUptimeSettings, readProbeRows, replaceProbes } from "../../services/uptime-collector.js"
import { readSpeedProbeCatalog, replaceSpeedProbeCatalog } from "../../services/speed-probe-catalog.js"
import { getTrafficFlowSettingsRow, toTrafficFlowSettingsDto } from "../../services/traffic-flow-settings.js"
import { getFlowListenerState } from "../../services/traffic-flow-ingest.js"
import { withFlowConfigurationLock, getFlowHostStatus, reconcileFlowHost } from "../../services/traffic-flow-host-agent.js"
import { agentSocket } from "../../services/traffic-flow-agent-client.js"
import { getStatistics, getStatisticsPivot, pivotDimsConflict } from "../../services/statistics-aggregate.js"
import { getServersApiPingSettings, updateServersApiPingSettings } from "../../services/servers-rest-ping-collector.js"
import { getInternetPathSettings, updateInternetPathSettings } from "../../services/internet-path-collector.js"
import { getGeoipSettings, updateGeoipSettings } from "../../services/geoip-settings.js"
import { buildGeoipStatus } from "../../services/geoip-status.js"
import { publicEvoBgpSettings, readEvoBgpSettings } from "../../services/evobgp-settings.js"
import { readEvoBgpCatalogPage } from "./evobgp-catalog.js"
import { readWanRuntimeForServer } from "../../services/wan-runtime.js"
import { readTrafficConfiguration, readUptimeConfiguration, readServersApiPingConfiguration } from "./telemetry-settings-configuration.js"
import { registerMcpService } from "./service-registry.js"
import { decryptSecret, fingerprint, McpError } from "./security.js"
import type { add } from "./actions.js"
import type { ActionContext, ChangeEvidence } from "./action-types.js"
const sid = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
const paging = { limit: z.number().int().min(1).max(200).default(100), offset: z.number().int().min(0).max(100000).default(0) }
async function serverSecret(input: Record<string, unknown>, context: ActionContext) {
const config = input.server as Record<string, unknown>, reference = input.passwordRef
if (!reference) return config
const { rows: [secret] } = await pool.query("SELECT * FROM mcp_secrets WHERE id=$1 AND created_by=$2", [reference, context.connection.owner_id])
const existing = input.serverId ? await getServerRowById(Number(input.serverId)) : undefined
const host = String(config.host ?? existing?.host ?? ""), port = Number(config.port ?? existing?.port ?? 443)
if (!secret || secret.purpose !== `server-login:${host}:${port}` || secret.server_id && Number(secret.server_id) !== input.serverId) throw new McpError("SECRET_UNAVAILABLE", "Секрет не выдан этому назначению")
return { ...config, password: await decryptSecret(secret.ciphertext) }
}
export function registerApplicationActions(register: typeof add) {
registerBackupStorageActions(register)
register("servers.wan-runtime.read", "mm_server_wan_runtime_get", "WAN IPv4/main: шлюз, интерфейс и адреса без доказательства forwarding", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: sid, ...paging }).strict(), {
read: async input => {
const server = await getServerRowById(Number(input.serverId))
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
const result = await readWanRuntimeForServer(server), offset = Number(input.offset), limit = Number(input.limit)
const uplinks = result.uplinks.slice(offset, offset + limit).map(uplink => ({ ...uplink, addresses: uplink.addresses.slice(0, 100), addressesTruncated: uplink.addresses.length > 100 }))
return { ...result, serverId: server.id, observedAt: new Date().toISOString(), uplinks, totalUplinks: result.uplinks.length, nextOffset: offset + limit < result.uplinks.length ? offset + limit : null,
incomplete: result.activeDefaultsTruncated || uplinks.length < result.uplinks.length || uplinks.some(uplink => uplink.addressesTruncated) }
},
})
register("servers.snapshots.read", "mm_server_snapshots_get", "История состояния выбранного сервера", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: sid, limit: z.number().int().min(1).max(200).default(50) }).strict(), {
read: async input => {
const server = await getServerRowById(Number(input.serverId))
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
const snapshots = await listServerSnapshots(server.id, Number(input.limit))
return { serverId: server.id, snapshots, incomplete: snapshots.length === Number(input.limit) }
},
})
register("servers.source-address.read", "mm_server_source_address_get", "IPv4 адрес управляющего host, без подтверждения адреса интерфейса", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: sid }).strict(), {
read: async input => {
const server = await getServerRowById(Number(input.serverId))
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
return { host: server.host, ipv4: await resolveRosSrcIpv4(server.host), purpose: "management-host-resolution", interfaceAddressVerified: false }
},
})
function query(id: string, tool: string, title: string, permission: string, read: NonNullable<Parameters<typeof register>[8]["read"]>, schema = z.object({}).strict()) {
register(id, tool, title, permission.split(":")[1], permission, "read", "global", schema, { read: async (input, context) => {
const ids = (await pool.query<{ id: number }>("SELECT id FROM servers")).rows.map(row => Number(row.id))
const { rows: grants } = await pool.query("SELECT payload FROM mcp_grants WHERE connection_id=$1 AND revoked_at IS NULL AND payload->>'credentialId'=$2", [context.connection.id, context.principal.credentialId])
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id) || grants.some(grant => !grant.payload.scope?.serverIds?.includes(id)))) throw new McpError("SERVER_DENIED", "Глобальный обзор содержит серверы вне эффективной области доступа")
return read(input, context)
} })
}
function change(id: string, tool: string, title: string, permission: string, schema: z.ZodObject,
current: (input: Record<string, unknown>) => Promise<unknown>, execute: (input: Record<string, unknown>) => Promise<unknown>, serverScoped = false) {
const dependencyIds = async () => (await pool.query<{ id: number }>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => Number(row.id))
const observed = async (input: Record<string, unknown>) => serverScoped ? current(input) : { value: await current(input), serverIds: await dependencyIds() }
const verifiedConfiguration = ["alerts.replace", "backups.delete", "uptime.ping.probes.replace", "uptime.speed.probes.replace", "netflow.host.reconcile"].includes(id) || id.endsWith(".update")
registerMcpService(id, { current: observed, execute, verify: async (input, result) => {
if (input.settings && typeof input.settings === "object") {
const saved = await current(input) as Record<string, unknown>
return !!saved && Object.entries(input.settings).every(([key, value]) => fingerprint(saved[key]) === fingerprint(value))
}
if (id === "alerts.replace") return !!result && fingerprint(await current(input)) === fingerprint({ revision: (result as { revision: unknown }).revision })
if (id === "backups.delete") return !await getBackupById(String(input.backupId))
if (id === "netflow.host.reconcile") {
const status = await getFlowHostStatus()
return status.reachable && status.interfaceUp && status.configMatches === true
}
if (Array.isArray(input.probes)) {
const saved = await current(input) as Record<string, unknown>[]
return saved.length === input.probes.length && input.probes.every((probe, index) => Object.entries(probe).every(([key, value]) => fingerprint(saved[index][key]) === fingerprint(key === "intervalSec" && Number(value) > 0 ? Math.max(5, Number(value)) : value)))
}
return result !== undefined
} })
register(id, tool, title, permission.split(":")[1], permission, "write", serverScoped ? "servers" : "global", schema, {
...(["traffic.settings.update", "uptime.settings.update", "servers.api-ping.settings.update"].includes(id) ? { version: 2 } : {}),
prepare: async (input, context) => {
const serverId = typeof input.serverId === "number" ? input.serverId : undefined
if (serverId && !context.connection.policy.scope.serverIds.includes(serverId)) throw new McpError("SERVER_DENIED", "Сервер не разрешён")
const before = await observed(input), ids = serverScoped ? [serverId!] : (before as { serverIds: number[] }).serverIds
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная настройка затрагивает серверы вне выданной области")
const objects: ChangeEvidence["objects"] = ids.map(serverId => ({ kind: "server", id: String(serverId), serverId }))
const domain = ({ "traffic.settings.update": "traffic-settings", "uptime.settings.update": "uptime-settings", "alerts.replace": "alerts-configuration", "backups.schedule.update": "backup-schedule", "certificates.renew.update": "certificate-renewal", "netflow.host.reconcile": "netflow-host", "servers.api-ping.settings.update": "servers-api-ping-settings", "internet-path.settings.update": "internet-path-settings" } as Record<string, string>)[id]
if (domain) objects.push({ kind: domain, id: "1" })
if (id === "geoip.settings.update") objects.push({ kind: "geoip-settings", id: "1" })
if (Array.isArray(input.probes)) {
if (input.probes.some(probe => [probe.srcServerId, probe.dstServerId].filter(value => value !== undefined).some(value => !ids.includes(Number(value))))) throw new McpError("SERVER_DENIED", "Проба ссылается на неизвестный или недоступный сервер")
const old = (before as { value: { id: string }[] }).value
objects.push(...[...new Set([...old.map(probe => probe.id), ...input.probes.map(probe => String(probe.id))])].map(probeId => ({ kind: id.includes(".ping.") ? "ping-probe" : "speed-probe", id: probeId })))
}
return { target: serverScoped ? { scope: "servers", serverIds: [serverId!] } : { scope: "global" }, objects, snapshots: [], before, steps: [{ kind: "service", service: id, input }], verificationScope: verifiedConfiguration ? "configuration" : "acknowledgement", warnings: [...(id === "geoip.settings.update" ? ["При включённом GeoIP обновление расписания запускает проверку зеркала сразу и затем по интервалу. Подтверждается сохранение настроек; доставка и готовность баз проверяются отдельно через состояние GeoIP. Отключение не отменяет уже начатую загрузку."] : []), ...(!verifiedConfiguration ? ["Подтверждается приём/завершение команды сервисом. Итоговое состояние внешней системы требует отдельной проверки."] : []), ...(Array.isArray(input.probes) ? ["Заменяется весь каталог проб, включая удаление отсутствующих ID. Включённые bandwidth-пробы создают повторяющуюся сетевую нагрузку по расписанию."] : []), ...(serverScoped ? [] : ["Глобальная операция затрагивает всё приложение. Проверен полный текущий каталог серверов; его изменение инвалидирует план."])], requiresHuman: true }
},
})
}
for (const action of ["create", "update", "delete"] as const) {
const schema = action === "delete" ? z.object({ serverId: sid }).strict() : z.object({ ...(action === "update" ? { serverId: sid } : {}), server: (action === "create" ? serverCreateSchema.omit({ password: true }) : serverUpdateSchema.omit({ password: true })).strict(), passwordRef: z.string().uuid().optional() }).strict()
const id = `servers.${action}`
registerMcpService(id, {
current: async input => input.serverId ? getServerRowById(Number(input.serverId)) : null,
execute: async input => {
if (action === "delete") { await deleteServer(Number(input.serverId)); return { deleted: true } }
const config = action === "create" ? serverCreateSchema.parse(input.resolvedServer) : serverUpdateSchema.parse(input.resolvedServer)
return action === "create" ? createServer(config as z.infer<typeof serverCreateSchema>) : updateServer(Number(input.serverId), config)
},
verify: async (input, result) => {
if (action === "delete") return !await getServerRowById(Number(input.serverId))
const id = action === "create" ? Number((result as { id?: number } | undefined)?.id) : Number(input.serverId)
const saved = await getServerRowById(id)
if (!saved) return false
const expected = action === "create" ? serverCreateSchema.parse(input.resolvedServer) : serverUpdateSchema.parse(input.resolvedServer)
return Object.entries(expected).every(([key, value]) => fingerprint(saved[key as keyof typeof saved]) === fingerprint(value))
},
})
register(id, `mm_servers_${action}_plan`, `Серверы: ${action}`, "servers", "mm:servers:write", "write", action === "create" ? "global" : "servers", schema, {
prepare: async (input, context) => {
const before = input.serverId ? await getServerRowById(Number(input.serverId)) : null
if (action !== "create" && !before) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
const config = input.server as Record<string, unknown> | undefined
if (action === "update" && ["host", "port", "username", "useSsl", "verifySsl"].some(key => config?.[key] !== undefined && config[key] !== before?.[key as keyof typeof before]) && !input.passwordRef) throw new McpError("SECRET_UNAVAILABLE", "Для изменения параметров подключения нужен секрет, привязанный к назначению")
const resolvedServer = action === "delete" ? undefined : await serverSecret(input, context)
return { target: action === "create" ? { scope: "global" } : { scope: "servers", serverIds: [Number(input.serverId)] }, objects: input.serverId ? [{ kind: "server", id: String(input.serverId), serverId: Number(input.serverId) }] : [], snapshots: [], before,
steps: [{ kind: "service", service: id, input: { ...input, resolvedServer } }], requiresHuman: true, warnings: ["Новый сервер не добавляется автоматически в разрешения MCP."] }
},
})
}
query("alerts.read", "mm_alerts_get", "Оповещения и история", "mm:alerts:read", async () => getAlertsOverview())
query("alerts.outbox.read", "mm_alerts_outbox_get", "Очередь уведомлений", "mm:alerts:read", async () => getAlertsOutboxOverview())
change("alerts.replace", "mm_alerts_update_plan", "Настройка правил оповещений", "mm:alerts:write", z.object({ configuration: putRulesSchema }).strict(),
async () => { const result = await getAlertsOverview(); return { revision: result.revision } },
async input => { const result = await replaceAlertsConfig(putRulesSchema.parse(input.configuration)); await refreshScheduler(); return result })
query("scheduler.read", "mm_scheduler_get", "Состояние планировщика", "mm:settings:admin", async () => getSchedulerStatus())
query("evobgp.settings.read", "mm_evobgp_settings_get", "Публичные настройки интеграции EvoBGP", "mm:settings:admin", async () => publicEvoBgpSettings(await readEvoBgpSettings()))
query("evobgp.catalog.read", "mm_evobgp_catalog_get", "Каталог EvoBGP: модули, ASN, префиксы и Community", "mm:settings:admin", readEvoBgpCatalogPage, z.object(paging).strict())
const trafficSettings = z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(5).max(3600).optional(), retentionDays: z.number().int().min(1).max(365).optional() }).strict().refine(value => Object.keys(value).length > 0)
const uptimeSettings = z.object({ enabled: z.boolean().optional(), resourcesEnabled: z.boolean().optional(), pingEnabled: z.boolean().optional(), speedEnabled: z.boolean().optional(), intervalSec: z.number().int().min(5).max(86400).optional(), probeIntervalSec: z.number().int().min(5).max(86400).optional(), speedIntervalSec: z.number().int().min(10).max(86400).optional(), retentionDays: z.number().int().min(1).max(365).optional() }).strict().refine(value => Object.keys(value).length > 0)
query("traffic.settings.read", "mm_traffic_settings_get", "Настройки сбора трафика", "mm:traffic:read", async () => getTrafficSettings())
query("geoip.read", "mm_geoip_get", "Состояние локальных баз GeoIP и обновлений", "mm:traffic:read", async () => buildGeoipStatus())
const geoipSettings = geoipSettingsPatchSchema.strict().refine(value => Object.keys(value).length > 0)
change("geoip.settings.update", "mm_geoip_settings_update_plan", "План настроек обновления GeoIP", "mm:traffic:write", z.object({ settings: geoipSettings }).strict(), async () => {
const settings = await getGeoipSettings()
return { enabled: settings.enabled, updateIntervalSec: settings.updateIntervalSec }
}, async input => {
const settings = await updateGeoipSettings(geoipSettings.parse(input.settings))
await refreshScheduler()
return { settings, verificationScope: "configuration-persistence" }
})
change("traffic.settings.update", "mm_traffic_settings_update_plan", "План настроек сбора трафика", "mm:traffic:write", z.object({ settings: trafficSettings }).strict(), readTrafficConfiguration, async input => { const result = await updateTrafficSettings(trafficSettings.parse(input.settings)); await refreshScheduler(); return result })
query("uptime.settings.read", "mm_uptime_settings_get", "Настройки мониторинга и проб", "mm:uptime:read", async () => readUptimeSettings())
change("uptime.settings.update", "mm_uptime_settings_update_plan", "План расписания мониторинга и проб", "mm:uptime:write", z.object({ settings: uptimeSettings }).strict(), readUptimeConfiguration, async input => { const result = await updateUptimeSettings(uptimeSettings.parse(input.settings)); await refreshScheduler(); return result })
query("netflow.settings.read", "mm_netflow_settings_get", "Публичные настройки NetFlow", "mm:traffic:read", async () => toTrafficFlowSettingsDto(getFlowListenerState()))
query("scheduler.runs", "mm_scheduler_runs_list", "История заданий", "mm:settings:admin", async input => listSchedulerRuns({ limit: Number(input.limit), offset: Number(input.offset), jobKey: input.jobKey as string | undefined }), z.object({ ...paging, jobKey: z.enum(JOB_KEYS).optional() }).strict())
query("servers.api-ping.settings.read", "mm_servers_api_ping_settings_get", "Настройки API-ping серверов", "mm:servers:read", async () => getServersApiPingSettings())
change("servers.api-ping.settings.update", "mm_servers_api_ping_settings_update_plan", "План API-ping серверов", "mm:servers:write", z.object({ settings: z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(10).max(86400).optional() }).strict().refine(value => Object.keys(value).length > 0) }).strict(), readServersApiPingConfiguration, async input => { const result = await updateServersApiPingSettings(input.settings as Parameters<typeof updateServersApiPingSettings>[0]); await refreshScheduler(); return result })
query("internet-path.settings.read", "mm_internet_path_settings_get", "Настройки наблюдений Internet path", "mm:network:read", async () => getInternetPathSettings())
const readInternetPathConfiguration = async () => {
const row = await getInternetPathSettings()
// Collection also updates updatedAt: bind plans to configuration, not run status.
return { enabled: row.enabled, intervalSec: row.intervalSec, retentionDays: row.retentionDays }
}
change("internet-path.settings.update", "mm_internet_path_settings_update_plan", "План сбора Internet path", "mm:network:write", z.object({ settings: z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(30).max(86400).optional(), retentionDays: z.number().int().min(1).max(365).optional() }).strict().refine(value => Object.keys(value).length > 0) }).strict(), readInternetPathConfiguration, async input => { const result = await updateInternetPathSettings(input.settings as Parameters<typeof updateInternetPathSettings>[0]); await refreshScheduler(); return result })
query("netflow.host.read", "mm_netflow_host_get", "Состояние хоста NetFlow", "mm:settings:admin", async () => getFlowHostStatus())
change("netflow.host.reconcile", "mm_netflow_host_reconcile_plan", "План применения сохранённой конфигурации хоста NetFlow", "mm:settings:admin", z.object({}).strict(), async () => {
if (!agentSocket()) throw new McpError("UNSUPPORTED_CAPABILITY", "Локальный агент NetFlow не настроен", 409)
const row = await getTrafficFlowSettingsRow()
return { configurationHash: fingerprint({ collectorIp: row.collectorIp, prefix: row.prefix, flowListenPort: row.flowListenPort, wgListenPort: row.wgListenPort, hostPublicKey: row.hostPublicKey, hostPrivateKey: row.hostPrivateKey, peersJson: row.peersJson }) }
}, async () => withFlowConfigurationLock(() => reconcileFlowHost()))
const pingCatalog = async () => (await readProbeRows()).map(row => ({ id: row.id, srcServerId: row.srcServerId, srcInterface: row.srcInterface, name: row.name, target: row.target, filter: row.probeFilter, enabled: row.enabled, showOnDashboard: row.showOnDashboard, intervalSec: row.intervalSec }))
const speedCatalog = async () => (await readSpeedProbeCatalog()).map(row => ({ id: row.id, srcServerId: row.srcServerId, dstServerId: row.dstServerId, srcInterface: row.srcInterface, dstInterface: row.dstInterface, protocol: row.protocol, direction: row.direction, durationSec: row.durationSec, enabled: row.enabled }))
query("uptime.ping.probes.read", "mm_ping_probes_get", "Каталог Ping-проб", "mm:uptime:read", pingCatalog)
query("uptime.speed.probes.read", "mm_speed_probes_get", "Каталог bandwidth-проб", "mm:uptime:read", speedCatalog)
const boundedCatalog = (item: z.ZodObject) => z.object({ probes: z.array(item.strict()).max(500).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID пробы") }).strict()
change("uptime.ping.probes.replace", "mm_ping_probes_replace_plan", "План полного каталога Ping-проб", "mm:uptime:write", boundedCatalog(pingProbeWriteSchema), pingCatalog, async input => { await replaceProbes(input.probes as PingProbeWrite[]); await refreshScheduler(); return { saved: true } })
change("uptime.speed.probes.replace", "mm_speed_probes_replace_plan", "План полного каталога bandwidth-проб", "mm:uptime:write", boundedCatalog(speedProbeWriteSchema.safeExtend({ durationSec: z.number().int().min(3).max(30) })), speedCatalog, async input => { await replaceSpeedProbeCatalog(input.probes as Parameters<typeof replaceSpeedProbeCatalog>[0]); await refreshScheduler(); return { saved: true } })
change("scheduler.run", "mm_scheduler_run_plan", "Запуск задания", "mm:settings:admin", z.object({ jobKey: z.enum(JOB_KEYS) }).strict(), async () => null, async input => runSchedulerJobNow(String(input.jobKey)))
change("scheduler.refresh", "mm_scheduler_refresh_plan", "Перечитать расписания", "mm:settings:admin", z.object({}).strict(), async () => null, async () => { await refreshScheduler(); return { refreshed: true } })
query("events.read", "mm_events_list", "События приложения", "mm:dashboard:read", async input => ({ events: await readEvents({ limit: Number(input.limit) }) }), z.object({ limit: z.number().int().min(1).max(200).default(100) }).strict())
register("backups.jobs.read", "mm_backup_job_get", "Состояние фоновой backup-задачи приложения", "backups", "mm:backups:read", "read", "servers", z.object({ jobId: z.string().min(1).max(200), ...paging }).strict(), {
read: async (input, context) => {
let result: ReturnType<typeof projectScopedBackupJob>
try { result = projectScopedBackupJob(readBackupJob(String(input.jobId)), new Set(context.connection.policy.scope.serverIds), Number(input.offset), Number(input.limit)) }
catch { throw new McpError("STATE_UNAVAILABLE", "Прогресс backup-задачи не подтверждён", 503) }
if (!result) throw new McpError("NOT_FOUND", "Backup-задача не найдена в разрешённой области", 404)
const rows = await pool.query("SELECT id FROM servers WHERE id=ANY($1::bigint[])", [result.serverIds])
if (rows.rows.length !== result.serverIds.length) throw new McpError("NOT_FOUND", "Устройства backup-задачи отсутствуют в каталоге", 404)
return result
},
})
register("backups.list", "mm_backups_list", "Резервные копии", "backups", "mm:backups:read", "read", "servers", z.object({ serverId: sid, ...paging }).strict(), {
read: async input => {
const { rows } = await pool.query<{ id: string }>("SELECT id FROM backup_entries WHERE server_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", [String(input.serverId), input.limit, input.offset])
return { items: await Promise.all(rows.map(row => getBackupById(row.id))) }
},
})
change("backups.create", "mm_backups_create_plan", "Создание backup", "mm:backups:write", z.object({ serverId: sid, notes: z.string().max(1000).default("") }).strict(), async input => getServerRowById(Number(input.serverId)), async input => runBackupForServer(String(input.serverId), "manual", String(input.notes)), true)
for (const action of ["restore", "delete"] as const) {
change(`backups.${action}`, `mm_backups_${action}_plan`, `Backup: ${action}`, "mm:backups:write", z.object({ serverId: sid, backupId: z.string().min(1).max(200) }).strict(), async input => {
const backup = await getBackupById(String(input.backupId)); if (!backup || backup.serverId !== input.serverId) throw new McpError("NOT_FOUND", "Резервная копия не найдена", 404); return backup
}, async input => action === "restore" ? restoreBackupToDevice(String(input.backupId)) : deleteBackupRecord(String(input.backupId)), true)
}
query("backups.schedule.read", "mm_backup_schedule_get", "Расписание backup", "mm:backups:read", async () => getBackupScheduleSettings())
query("backups.storage.read", "mm_backup_storage_get", "Состояние хранилища backup", "mm:backups:read", async () => getBackupStorageSettings())
change("backups.schedule.update", "mm_backup_schedule_update_plan", "Изменение расписания backup", "mm:backups:write", z.object({ settings: putBackupScheduleSettingsSchema }).strict(), async () => getBackupScheduleSettings(), async input => { const result = await updateBackupScheduleSettings(putBackupScheduleSettingsSchema.parse(input.settings)); await refreshScheduler(); return result })
change("backups.storage.test", "mm_backup_storage_test_plan", "Проверка хранилища backup", "mm:backups:write", z.object({}).strict(), async () => getBackupStorageSettings(), async () => testBackupStorageConnection())
change("backups.storage.sync", "mm_backup_storage_sync_plan", "Синхронизация backup из S3", "mm:backups:write", z.object({}).strict(), async () => null, async () => syncBackupsFromS3())
query("certificates.acme.read", "mm_acme_settings_get", "Публичные настройки ACME", "mm:certificates:read", async () => getAcmeSettingsPublic())
query("certificates.renew.read", "mm_certificate_renew_get", "Настройки продления сертификатов", "mm:certificates:read", async () => getCertificateRenewSettings())
change("certificates.renew.update", "mm_certificate_renew_update_plan", "Настройка продления сертификатов", "mm:certificates:write", z.object({ settings: z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(60).max(86400).optional(), renewBeforeDays: z.number().int().min(1).max(365).optional() }).strict() }).strict(), async () => getCertificateRenewSettings(), async input => updateCertificateRenewSettings(input.settings as Parameters<typeof updateCertificateRenewSettings>[0]))
for (const table of ["traffic_samples", "uptime_resource_samples", "uptime_probe_samples"] as const) {
register(`monitoring.${table}.read`, `mm_${table}_query`, `История: ${table}`, "monitoring", table === "traffic_samples" ? "mm:traffic:read" : "mm:uptime:read", "read", "servers", z.object({ serverId: sid, ...paging }).strict(), {
read: async input => {
if (table === "uptime_probe_samples") {
const { rows } = await pool.query("SELECT s.* FROM uptime_probe_samples s JOIN uptime_probes p ON p.id=s.probe_id WHERE p.src_server_id=$1 ORDER BY sampled_at DESC LIMIT $2 OFFSET $3", [input.serverId, input.limit, input.offset]); return { items: rows }
}
const { rows } = await pool.query(`SELECT * FROM ${table} WHERE server_id=$1 ORDER BY sampled_at DESC LIMIT $2 OFFSET $3`, [input.serverId, input.limit, input.offset]); return { items: rows }
},
})
}
register("monitoring.flow.analytics", "mm_netflow_analytics_get", "NetFlow выбранного сервера", "traffic", "mm:traffic:read", "read", "servers", z.object({ serverId: sid, minutes: z.number().int().min(1).max(1440).default(15), interface: z.string().min(1).max(100).optional() }).strict(), {
read: async input => {
const result = await buildFlowAnalytics({ serverId: Number(input.serverId), minutes: Number(input.minutes), iface: input.interface as string | undefined, skipHeavy: true })
return { serverId: input.serverId, source: "PostgreSQL/flow_buckets", observedAt: new Date().toISOString(), bpsNow: result.bpsNow, bytes: result.bytes, packets: result.packets, conversations: result.conversations, uniqueSrc: result.uniqueSrc, uniqueDst: result.uniqueDst, rxSeries: result.rxSeries, txSeries: result.txSeries, applications: result.applications, protocols: result.protocols, countries: result.countries, categories: result.categories, services: result.services,
sources: result.sources.map(row => ({ ...row, label: row.id })), destinations: result.destinations.map(row => ({ ...row, label: row.id })) }
},
})
register("monitoring.flow.monthly", "mm_netflow_monthly_get", "Месячная статистика NetFlow выбранного сервера", "traffic", "mm:traffic:read", "read", "servers", z.object({ serverId: sid, month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/) }).strict(), {
read: async input => ({ serverId: input.serverId, source: "PostgreSQL/flow_daily_dims", observedAt: new Date().toISOString(), statistics: await getFlowMonthly(String(input.month), Number(input.serverId), { maxKeys: 10000 }) }),
})
const period = { serverId: sid, from: z.string().date(), to: z.string().date() }
for (const pivot of [false, true]) {
const schema = (pivot ? statisticsPivotQuerySchema : statisticsQuerySchema).extend(period).strict().superRefine((input, context) => {
const span = Date.parse(input.to) - Date.parse(input.from)
if (span < 0 || span > 30 * 86400000) context.addIssue({ code: "custom", message: "Статистика MCP ограничена 31 календарным днём" })
})
register(pivot ? "statistics.pivot" : "statistics.read", pivot ? "mm_statistics_pivot_get" : "mm_statistics_get", pivot ? "Сводная статистика выбранного сервера" : "Статистика выбранного сервера", "traffic", "mm:traffic:read", "read", "servers", schema, {
read: async input => {
if (!pivot) return getStatistics(statisticsQuerySchema.parse(input))
const parsed = statisticsPivotQuerySchema.parse(input)
if (pivotDimsConflict(parsed.row, parsed.col)) throw new McpError("INVALID_INPUT", "Строки и столбцы сводной должны отличаться", 400)
return getStatisticsPivot(parsed)
},
})
}
}