Compare commits

...
7 changed files with 260 additions and 24 deletions

No files matched your search

+10 -4
View File
@@ -16,7 +16,7 @@ permissions:
packages: write
jobs:
backend-preview:
if: ${{ gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' }}
if: ${{ gitea.event_name == 'workflow_dispatch' && (gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' || gitea.ref == 'refs/heads/codex/mcp-portal-revoke-preview-20261115') }}
runs-on: ubuntu-latest
timeout-minutes: 40
env:
@@ -43,7 +43,10 @@ jobs:
run: |
set -euo pipefail
test "$DISPATCH_EVENT" = workflow_dispatch
test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103
case "$DISPATCH_REF" in
refs/heads/codex/mcp-acceptance-current-20261103|refs/heads/codex/mcp-portal-revoke-preview-20261115) ;;
*) printf 'Preview ref is not allowed: %s\n' "$DISPATCH_REF" >&2; exit 1 ;;
esac
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
@@ -139,7 +142,7 @@ jobs:
if-no-files-found: error
retention-days: 14
frontend-preview:
if: ${{ gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' }}
if: ${{ gitea.event_name == 'workflow_dispatch' && (gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' || gitea.ref == 'refs/heads/codex/mcp-portal-revoke-preview-20261115') }}
runs-on: ubuntu-latest
timeout-minutes: 40
env:
@@ -179,7 +182,10 @@ jobs:
run: |
set -euo pipefail
test "$DISPATCH_EVENT" = workflow_dispatch
test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103
case "$DISPATCH_REF" in
refs/heads/codex/mcp-acceptance-current-20261103|refs/heads/codex/mcp-portal-revoke-preview-20261115) ;;
*) printf 'Preview ref is not allowed: %s\n' "$DISPATCH_REF" >&2; exit 1 ;;
esac
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
+35
View File
@@ -180,6 +180,41 @@ try {
assert.deepEqual(listed.json().profiles, [{ ...profile, state: { reason: profile.state.reason, candidateId: "wan-b", failureCount: 3 } }])
assert.ok(!listed.body.includes("NEVER_PRIVATE_"))
} finally { pool.query = originalQuery }
const monitoredOperation = "58d8e404-7209-4fd3-88b3-91f9bed9425a"
const monitoringHeaders = { authorization: "Bearer " + await token(["mm:mcp:read"]) }
const monitoringUrl = `/api/mcp/operations?operationId=${monitoredOperation}&limit=1`
assert.equal((await app.inject({ url: monitoringUrl })).statusCode, 401)
let operationVisible = true
let stepReads = 0
pool.query = (async (sql: string, values: unknown[]) => {
if (sql.includes("FROM mcp_operation_steps")) {
stepReads++
assert.match(sql, /SELECT step_index,server_id,state,finished_at/)
assert.match(sql, /LIMIT 101$/)
assert.deepEqual(values, [monitoredOperation])
return { rows: Array.from({ length: 101 }, (_, step_index) => ({ step_index, state: "verified" })) }
}
assert.match(sql, /c\.owner_id=\$2/)
assert.match(sql, /r\.id=\$3::uuid/)
if (sql.startsWith("SELECT count")) {
assert.deepEqual(values.slice(0, 3), [false, "human-admin", monitoredOperation])
return { rows: [{ total: operationVisible ? 1 : 0 }] }
}
assert.match(sql, /FROM mcp_operations r/)
assert.deepEqual(values, [false, "human-admin", monitoredOperation, 2])
return { rows: operationVisible ? [{ id: monitoredOperation, state: "partial" }] : [] }
}) as typeof pool.query
try {
const result = await app.inject({ url: monitoringUrl, headers: monitoringHeaders })
assert.equal(result.statusCode, 200, result.body)
assert.equal(result.json().items[0].steps.length, 100)
assert.equal(result.json().items[0].stepsTruncated, true)
assert.equal(result.json().items[0].state, "partial")
operationVisible = false
assert.deepEqual((await app.inject({ url: monitoringUrl, headers: monitoringHeaders })).json(), { items: [], total: 0, nextCursor: null })
assert.equal(stepReads, 1, "An inaccessible operation must not load its steps")
assert.equal((await app.inject({ url: monitoringUrl.replace("/operations", "/plans"), headers: monitoringHeaders })).statusCode, 400)
} finally { pool.query = originalQuery }
env.authRequired = false
assert.equal((await app.inject({ url: "/api/mcp/catalog" })).statusCode, 503)
assert.equal((await app.inject({ method: "POST", url: "/api/mcp/policy/preview", payload, headers: admin })).statusCode, 503)
+9 -1
View File
@@ -238,6 +238,10 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options
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.operationId) {
if (table !== "operations") throw new McpError("INVALID_TARGET", "ID операции используется только в журнале операций", 400)
clauses.push(`r.id=${bind(query.operationId)}::uuid`)
}
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)}`)
@@ -254,8 +258,12 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options
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 steps = table === "operations" && query.operationId && entries.length > 0
? (await pool.query(`SELECT step_index,server_id,state,finished_at FROM mcp_operation_steps
WHERE operation_id=$1 ORDER BY step_index LIMIT 101`, [query.operationId])).rows
: undefined
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 }
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>, ...(steps && { steps: steps.slice(0, 100), stepsTruncated: steps.length > 100 }), 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 => {
+135 -14
View File
@@ -1,11 +1,12 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { useTable, type ColumnDef } from "@tanstack/react-table"
import { BotIcon, CopyIcon, PlusIcon, RefreshCwIcon, ShieldIcon, StopCircleIcon } from "lucide-react"
import { toast } from "sonner"
import { mcpConnectionCreateSchema, mcpTargetSchema, type McpConnection, type McpOperationDescriptor, type McpPolicyDecision } from "@mmapp/contracts/mcp"
import { mcpConnectionCreateSchema, mcpPageQuerySchema, mcpTargetSchema, type McpConnection, type McpOperationDescriptor, type McpPolicyDecision } from "@mmapp/contracts/mcp"
import { routeAiControllerBlockReason, routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
import { useDataSource } from "@/lib/data-source"
import { can, getClaims, hasPermission } from "@/lib/auth"
@@ -21,7 +22,7 @@ import { ObjectPermissionPreview, PolicyPreviewDecision } from "./object-permiss
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
import { DataGrid, DataGridContainer, DataGridTable, DataGridScrollArea, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import { Textarea } from "@/components/ui/textarea"
@@ -393,6 +394,10 @@ const recordQueryFilters = {
function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFilters }) {
const urlParams = useSearchParams(), [selected, setSelected] = useState<RecordRow | null>(null)
const [startedOperation, setStartedOperation] = useState<string | null>(null)
const operationId = kind === "operations" ? urlParams.get("operation") : null
const operationLink = new URLSearchParams(urlParams)
if (startedOperation) operationLink.set("operation", startedOperation)
const fields: readonly string[] = recordQueryFilters[kind]
const filterValue = (field: string) => urlParams.get(kind + "." + field) ?? ""
const cursor = filterValue("cursor")
@@ -405,19 +410,53 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
const queryKey = queryFields.toString()
const load = useCallback((signal: AbortSignal) => api.records(kind, new URLSearchParams(queryKey), signal), [api, kind, queryKey])
const query = useQuery(load, `${kind}?${queryKey}`)
const journalConnection = kind === "requests" ? filterValue("connectionId") : ""
const navigationClass = buttonVariants({ variant: "outline", className: "self-start" })
const selectRecord = useCallback((row: RecordRow) => {
if (kind !== "operations") { setSelected(row); return }
const url = new URL(window.location.href)
url.searchParams.set("operation", String(row.id))
window.history.pushState(null, "", url.pathname + url.search)
}, [kind])
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
{ accessorKey: "created_at", header: "Время", size: 170, cell: ({ row }) => date(row.original.created_at ?? row.original.createdAt ?? row.original.detected_at) },
{ id: "action", header: "Действие", size: 260, cell: ({ row }) => String(row.original.tool ?? row.original.operationId ?? row.original.action ?? row.original.id) },
{ id: "state", header: "Результат", cell: ({ row }) => <Badge variant={["completed", "applied"].includes(String(row.original.status ?? row.original.state)) ? "success-light" : "info-light"}>{String(row.original.status ?? row.original.state ?? "событие")}</Badge> },
{ accessorKey: "connection_id", header: "Подключение", size: 250, cell: ({ row }) => <span className="font-mono text-xs">{String(row.original.connection_id ?? row.original.connectionId ?? "—")}</span> },
{ accessorKey: "error_code", header: "Причина", cell: ({ row }) => String(row.original.error_code ?? row.original.verification ?? "—") },
{ id: "details", header: "", cell: ({ row }) => <Button size="sm" variant="ghost" onClick={() => setSelected(row.original)}>Открыть</Button> },
], [])
{ id: "details", header: "", cell: ({ row }) => <Button size="sm" variant="ghost" onClick={() => selectRecord(row.original)}>Открыть</Button> },
], [selectRecord])
const pendingPlanRequests = useRef(new Set<string>())
const [pendingPlans, setPendingPlans] = useState<ReadonlySet<string>>(() => new Set())
const selectedPlanPending = kind === "plans" && !!selected && pendingPlans.has(String(selected.id))
const planExpiry = kind === "plans" && typeof selected?.expiresAt === "string" ? Date.parse(selected.expiresAt) : null
const [planClock, setPlanClock] = useState(0)
useEffect(() => {
if (planExpiry === null || !Number.isFinite(planExpiry)) return
let timer: ReturnType<typeof setTimeout>
const update = () => {
clearTimeout(timer)
const now = Date.now()
setPlanClock(now)
if (now < planExpiry) timer = setTimeout(update, Math.min(planExpiry - now, 2_147_483_647))
}
update()
document.addEventListener("visibilitychange", update)
return () => {
clearTimeout(timer)
document.removeEventListener("visibilitychange", update)
}
}, [planExpiry])
const planExpired = planExpiry !== null && (!Number.isFinite(planExpiry) || planExpiry <= planClock)
const currentPlan = kind === "plans" && selected ? query.data?.items.find(item => item.id === selected.id) : null
const planChanged = kind === "plans" && !!selected && (!currentPlan || query.error || pretty(currentPlan) !== pretty(selected))
const planActionDisabled = selectedPlanPending || planExpired || !!planChanged
const submitPlan = async (action: "approve" | "reject" | "apply") => {
if (!selected) return
if (!selected || planChanged) return
if (planExpiry !== null && (!Number.isFinite(planExpiry) || planExpiry <= Date.now())) {
setPlanClock(Date.now())
return
}
const plan = selected
const planId = String(plan.id)
const pending = pendingPlanRequests.current
@@ -426,8 +465,9 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
setPendingPlans(new Set(pending))
try {
if (action === "apply") {
await api.apply(planId)
toast.success("Операция принята; результат появится в списке операций")
const operation = await api.apply(planId)
setStartedOperation(operation.id)
toast.success("Запрос принят. Откройте выполнение операции для проверки результата.")
} else {
await api.decision(planId, action === "approve")
}
@@ -440,9 +480,20 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
setPendingPlans(new Set(pending))
}
}
const closeDetails = () => {
setSelected(null)
if (operationId) {
const url = new URL(window.location.href)
url.searchParams.delete("operation")
window.history.replaceState(null, "", url.pathname + url.search)
}
}
return (
<Panel title={kind === "requests" ? "Обращения" : kind === "plans" ? "Планы изменений" : kind === "operations" ? "Выполнение операций" : kind === "drift" ? "Расхождения конфигурации" : "Административный аудит"} description="Чтение, отказы и изменения доступны для расследования">
<ErrorMessage message={query.error} />
{startedOperation && <Link className={navigationClass} href={`/mcp?${operationLink}`}>Открыть выполнение операции</Link>}
{journalConnection && <Link className={navigationClass} href={`/mcp?${new URLSearchParams({ tab: "plans", "plans.connectionId": journalConnection, "operations.connectionId": journalConnection })}`}>Планы и операции подключения</Link>}
{journalConnection && can("mm:mcp:admin") && <Link className={navigationClass} href={`/mcp?${new URLSearchParams({ tab: "settings", "audit.connectionId": journalConnection })}#mcp-audit`}>Административный аудит подключения</Link>}
<p className="text-xs text-muted-foreground">Последнее успешное обновление: {date(query.updatedAt)}{query.error && query.data ? " · показаны ранее полученные данные" : ""}</p>
<RecordFilters fields={fields} value={filterValue} update={setFilter} />
<div className="flex flex-wrap gap-2">
@@ -450,7 +501,7 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
{fields.includes("status") && <Input className="max-w-xs" aria-label="Фильтр состояния" placeholder="Состояние" value={filterValue("status")} onChange={event => setFilter("status", event.target.value)} />}
<Button variant="outline" onClick={query.refresh}><RefreshCwIcon />Обновить</Button>
</div>
<Grid items={query.data?.items ?? []} columns={columns} total={query.data?.total} loading={!query.data && !query.error} onSelect={setSelected} />
<Grid items={query.data?.items ?? []} columns={columns} total={query.data?.total} loading={!query.data && !query.error} onSelect={selectRecord} />
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Найдено: {query.data?.total ?? "—"}</span>
<div className="flex gap-2">
@@ -458,10 +509,65 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
<Button variant="outline" disabled={!query.data?.nextCursor} onClick={() => setFilter("cursor", query.data?.nextCursor ?? "")}>Далее</Button>
</div>
</div>
<Dialog open={!!selected} onOpenChange={open => { if (!open) setSelected(null) }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}</DialogTitle><DialogDescription>Секретные поля скрыты. Подтверждение относится к этому содержимому.</DialogDescription></DialogHeader>{kind === "plans" && selected ? <PlanReview plan={selected} /> : <pre className="max-h-[55dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{pretty(selected)}</pre>}{kind === "operations" && selected && <ArtifactDownloads key={String(selected.id)} api={api} operation={selected} />}{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && <Button disabled={selectedPlanPending} onClick={() => { void submitPlan("apply") }}>Применить план</Button>}{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") && <div className="flex gap-2"><Button disabled={selectedPlanPending} onClick={() => { void submitPlan("approve") }}>Подтвердить план</Button><Button variant="outline" disabled={selectedPlanPending} onClick={() => { void submitPlan("reject") }}>Отклонить</Button></div>}</DialogContent></Dialog>
<Dialog open={!!selected || !!operationId} onOpenChange={open => { if (!open) closeDetails() }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}</DialogTitle>
<DialogDescription>{kind === "plans" ? "Подтверждение относится к показанному плану." : "Данные журнала с маскировкой секретных полей."}</DialogDescription>
</DialogHeader>
{operationId ? <OperationMonitor key={operationId} api={api} id={operationId} /> : kind === "plans" && selected ? <PlanReview plan={selected} /> : <pre className="max-h-[55dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{pretty(selected)}</pre>}
{planExpired && <p role="status" className="text-sm text-muted-foreground">Срок плана истёк. Подготовьте новый план.</p>}
{planChanged && !planExpired && <p role="status" className="text-sm text-muted-foreground">Текущая версия плана недоступна или изменилась. Откройте план заново.</p>}
{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && <Button disabled={planActionDisabled} onClick={() => { void submitPlan("apply") }}>Применить план</Button>}
{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") && <div className="flex gap-2"><Button disabled={planActionDisabled} onClick={() => { void submitPlan("approve") }}>Подтвердить план</Button><Button variant="outline" disabled={planActionDisabled} onClick={() => { void submitPlan("reject") }}>Отклонить</Button></div>}
</DialogContent>
</Dialog>
</Panel>
)
}
const operationStates: Record<string, string> = {
pending: "Ожидает выполнения", running: "Выполняется", applied: "Применена", failed: "Ошибка выполнения",
blocked: "Заблокирована", cancelled: "Отменена", partial: "Выполнена частично", unknown: "Исход не подтверждён",
}
const stepStates: Record<string, string> = { intent: "Записано намерение", applied: "Применён", acknowledged: "Получено подтверждение приёма", verified: "Результат проверен" }
function OperationMonitor({ api, id }: { api: McpApi; id: string }) {
const recoveryLink = new URLSearchParams(useSearchParams())
recoveryLink.delete("operation")
recoveryLink.set("reconcile", id)
const load = useCallback((signal: AbortSignal) => api.records("operations", new URLSearchParams({ operationId: id, limit: "1" }), signal), [api, id])
const query = useQuery(load, id)
const operation = query.data?.items.find(item => item.id === id)
const state = String(operation?.state ?? "")
const steps = Array.isArray(operation?.steps) ? operation.steps as RecordRow[] : []
return <>
<ErrorMessage message={query.error} />
<div className="flex flex-wrap items-center gap-2">
<span className="break-all font-mono text-xs">{id}</span>
<Button variant="outline" size="sm" onClick={query.refresh}>Обновить выполнение</Button>
</div>
<p aria-live="polite" className="text-sm">
{operation ? operationStates[state] ?? state : query.data ? "Операция отсутствует в доступном журнале" : query.error ? "Состояние недоступно" : "Получаем состояние операции…"}
{query.error && operation && " · последнее полученное состояние"}
</p>
{operation && <>
{(state === "partial" || state === "unknown") && <div className="flex flex-wrap items-center gap-2 text-sm">
<p className="text-muted-foreground">Перед обратным изменением сверьте фактическое состояние. Повтор может повторно изменить конфигурацию.</p>
{can("mm:mcp:admin") && <Link className={buttonVariants({ variant: "outline", size: "sm" })} href={`/mcp?${recoveryLink}#mcp-recovery`}>Перейти к сверке</Link>}
</div>}
{steps.length > 0 && <section aria-label="Шаги выполнения" className="rounded-md border p-3 text-sm">
<h3 className="font-medium">Шаги выполнения</h3>
<ol className="mt-2 list-inside list-decimal space-y-1">
{steps.map(step => <li key={String(step.step_index)}>{stepStates[String(step.state)] ?? String(step.state)}{step.server_id != null && ` · сервер ${step.server_id}`}{step.finished_at != null && ` · ${date(step.finished_at)}`}</li>)}
</ol>
{operation.stepsTruncated === true && <p className="mt-2 text-muted-foreground">Показаны первые 100 шагов.</p>}
</section>}
<pre className="max-h-[45dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{pretty(operation)}</pre>
{!query.error && <ArtifactDownloads api={api} operation={operation} />}
</>}
</>
}
function RecordFilters({ fields, value, update }: { fields: readonly string[]; value: (field: string) => string; update: (field: string, value: string) => void }) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@@ -554,13 +660,27 @@ function Permissions({ api, operations }: { api: McpApi; operations: McpOperatio
</Panel>
</div>
}
function OperationRecovery({ api }: { api: McpApi }) {
const [id, setId] = useState(""), [result, setResult] = useState<unknown>(null), [error, setError] = useState(""), [busy, setBusy] = useState(false)
function OperationRecovery({ api, operationId }: { api: McpApi; operationId: string }) {
const [id, setId] = useState(operationId), [result, setResult] = useState<unknown>(null), [error, setError] = useState(""), [busy, setBusy] = useState(false)
const target = id.trim()
const validId = mcpPageQuerySchema.shape.operationId.safeParse(target).success
const run = async (recover: boolean) => {
if (busy || !validId) return
setBusy(true); setError(""); setResult(null)
try { setResult(recover ? await api.recoverOperation(id) : await api.reconcileOperation(id)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
try { setResult(recover ? await api.recoverOperation(target) : await api.reconcileOperation(target)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
}
return <Panel title="Сверка и восстановление" description="Сверка читает состояние устройства. Восстановление создаёт отдельный план и проверяет отсутствие внешних изменений."><ErrorMessage message={error} /><Field label="ID исходной операции"><Input value={id} onChange={event => { setId(event.target.value); setResult(null) }} /></Field><div className="flex flex-wrap gap-2"><Button variant="outline" disabled={busy || !id} onClick={() => { void run(false) }}>Сверить состояние операции</Button>{can("mm:mcp:admin") && <Button disabled={busy || !id} onClick={() => { void run(true) }}>Подготовить обратный план</Button>}</div>{result !== null && <pre role="status" className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(result)}</pre>}</Panel>
return <section id="mcp-recovery">
<Panel title="Сверка и восстановление" description="Сверка читает состояние устройства. Восстановление создаёт отдельный план и проверяет отсутствие внешних изменений.">
<ErrorMessage message={error} />
<Field label="ID исходной операции"><Input value={id} disabled={busy} aria-invalid={!!target && !validId} onChange={event => { setId(event.target.value); setResult(null); setError("") }} /></Field>
{target && !validId && <p className="text-sm text-destructive">Укажите UUID операции из журнала.</p>}
<div className="flex flex-wrap gap-2">
<Button variant="outline" disabled={busy || !validId} onClick={() => { void run(false) }}>Сверить состояние операции</Button>
<Button disabled={busy || !validId} onClick={() => { void run(true) }}>Подготовить обратный план</Button>
</div>
{result !== null && <pre role="status" className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(result)}</pre>}
</Panel>
</section>
}
function ManagementChannels({ api }: { api: McpApi }) {
const channels = useQuery(useCallback(signal => api.managementChannels(signal), [api]))
@@ -646,9 +766,10 @@ function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: b
const params = useSearchParams(), initial = params.get("tab") as Tab | null
const oauthRequestId = params.get("oauthRequest")
const [tab, setTab] = useState<Tab>(initial && initial in tabs ? initial : "overview")
const recoveryId = params.get("reconcile") ?? ""
const api = useMemo(() => mcpApi(backendUrl), [backendUrl])
const [operations, setOperations] = useState<McpOperationDescriptor[]>([]), [error, setError] = useState("")
useEffect(() => { if (!live) return; const controller = new AbortController(); void api.catalog(controller.signal).then(catalog => setOperations(catalog.operations), failure => { if (!controller.signal.aborted) setError(errorText(failure)) }); return () => controller.abort() }, [api, live])
const changeTab = (value: unknown) => { const next = String(value) as Tab; setTab(next); const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState(null, "", url.pathname + url.search) }
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{oauthRequestId && <Consent key={oauthRequestId} api={api} id={oauthRequestId} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} operations={operations} /></TabsContent><TabsContent value="devices"><DeviceManagement api={api} /></TabsContent><TabsContent value="routing"><div className="flex flex-col gap-4"><RoutingProfiles api={api} /><RoutePathVerification api={api} /></div></TabsContent><TabsContent value="requests"><Records api={api} kind="requests" /></TabsContent><TabsContent value="plans"><div className="flex flex-col gap-4"><Records api={api} kind="plans" /><Records api={api} kind="operations" /><OperationRecovery api={api} /><Records api={api} kind="drift" /></div></TabsContent>{can("mm:mcp:admin") && <TabsContent value="settings"><div className="flex flex-col gap-4"><Settings api={api} /><RestCoverage api={api} /><ManagementChannels api={api} />{can("mm:settings:admin") && <DatabaseRestore api={api} />}</div></TabsContent>}</Tabs></>}</main></>
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{oauthRequestId && <Consent key={oauthRequestId} api={api} id={oauthRequestId} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} operations={operations} /></TabsContent><TabsContent value="devices"><DeviceManagement api={api} /></TabsContent><TabsContent value="routing"><div className="flex flex-col gap-4"><RoutingProfiles api={api} /><RoutePathVerification api={api} /></div></TabsContent><TabsContent value="requests"><Records api={api} kind="requests" /></TabsContent><TabsContent value="plans"><div className="flex flex-col gap-4"><Records api={api} kind="plans" /><Records api={api} kind="operations" /><OperationRecovery key={recoveryId} api={api} operationId={recoveryId} /><Records api={api} kind="drift" /></div></TabsContent>{can("mm:mcp:admin") && <TabsContent value="settings"><div className="flex flex-col gap-4"><Settings api={api} /><RestCoverage api={api} /><ManagementChannels api={api} />{can("mm:settings:admin") && <DatabaseRestore api={api} />}</div></TabsContent>}</Tabs></>}</main></>
}
+1
View File
@@ -149,6 +149,7 @@ export const mcpSettingsSchema = z.object({
export const mcpSettingsPatchSchema = mcpSettingsSchema.partial().strict().refine(value => Object.keys(value).length > 0)
export const mcpPageQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
operationId: z.string().uuid().optional(),
cursor: z.union([z.string().uuid(), z.string().regex(/^[1-9]\d{0,18}$/)]).optional(),
connectionId: z.string().uuid().optional(),
status: z.string().max(50).optional(),
+11 -5
View File
@@ -49,10 +49,16 @@ export function withAuthHeaders(init?: HeadersInit): Headers {
return headers
}
function handleUnauthorized(): never {
let portalHandoffStarted = false
function handleUnauthorized(authorization: string | null): never {
if (typeof window !== "undefined" && isAuthEnabled()) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
const token = getToken()
if (!portalHandoffStarted && (!token || authorization === `Bearer ${token}`)) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
portalHandoffStarted = true
}
}
throw new ApiClientError("Unauthorized", 401)
}
@@ -86,7 +92,7 @@ export async function requestJson<T>(
headers,
})
if (res.status === 401) handleUnauthorized()
if (res.status === 401) handleUnauthorized(headers.get("Authorization"))
if (res.status === 204) return undefined as T
@@ -116,7 +122,7 @@ export async function requestBlob(
...init,
headers,
})
if (res.status === 401) handleUnauthorized()
if (res.status === 401) handleUnauthorized(headers.get("Authorization"))
if (!res.ok) {
throw new ApiClientError(await parseErrorMessage(res), res.status)
}
+59
View File
@@ -137,6 +137,65 @@ test('synthetic: bind OAuth consent to the displayed request and submit only onc
expect(decisions).toHaveLength(1)
})
test('synthetic: concurrent unauthorized MCP reads hand off to Portal once', async ({ page, baseURL }) => {
await setup(page, ['mm:mcp:read'])
const portal = 'http://127.0.0.1:3165'
const operationPath = `/mcp?tab=plans&operation=${id}`
await page.route('**/api/auth/config', route => route.fulfill({ json: { required: true, portal_url: portal } }))
let revoked = false, refusals = 0
let delayedRefusal: Promise<void> | undefined
const headers = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'Authorization' }
await page.route(/\/api\/mcp\/(operations|plans|drift)\?/, async route => {
if (route.request().method() === 'OPTIONS') return route.fulfill({ status: 204, headers })
const selected = new URL(route.request().url()).searchParams.get('operationId') === id
if (selected && delayedRefusal) {
const wait = delayedRefusal
delayedRefusal = undefined
await wait
return route.fulfill({ status: 401, headers, json: { error: 'Unauthorized' } })
}
if (revoked) {
refusals++
return route.fulfill({ status: 401, headers, json: { error: 'Unauthorized' } })
}
return route.fulfill({ headers, json: { items: selected ? [{ id, state: 'applied', steps: [{ step_index: 0, state: 'verified' }] }] : [], total: selected ? 1 : 0, nextCursor: null } })
})
const handoffs: URL[] = []
await page.route(url => url.origin === portal && url.pathname === '/', async route => {
handoffs.push(new URL(route.request().url()))
await expect.poll(() => refusals).toBeGreaterThan(1)
await route.fulfill({ contentType: 'text/html', body: '<h1>Portal sign-in</h1>' })
})
await page.goto(operationPath)
const step = page.getByRole('region', { name: 'Шаги выполнения' }).getByText('Результат проверен')
await expect(step).toBeVisible()
const refresh = page.getByRole('button', { name: 'Обновить выполнение' })
let releaseRefusal!: () => void
delayedRefusal = new Promise<void>(resolve => { releaseRefusal = resolve })
try {
await Promise.all([
page.waitForRequest(request => request.method() === 'GET' && new URL(request.url()).searchParams.get('operationId') === id),
refresh.click(),
])
await page.evaluate(() => {
localStorage.setItem('mmapp_token', 'header.' + btoa(JSON.stringify({ sub: 'new-session', apps: ['mm'], permissions: ['mm:mcp:read'], exp: 4102444800 })) + '.signature')
})
} finally { releaseRefusal() }
await expect(page.getByRole('alert').filter({ hasText: 'Unauthorized' })).toBeVisible()
expect(handoffs).toHaveLength(0)
revoked = true
await refresh.click()
await expect(page.getByRole('heading', { name: 'Portal sign-in' })).toBeVisible()
expect(handoffs).toHaveLength(1)
const callback = new URL(handoffs[0].searchParams.get('return_to')!)
expect(callback.origin).toBe(new URL(baseURL!).origin)
expect(callback.pathname).toBe('/auth/callback')
expect(callback.searchParams.get('next')).toBe(operationPath)
await expect(step).toHaveCount(0)
})
test('synthetic: deny OAuth consent without an available connection', async ({ page, baseURL }) => {
await setup(page, ['mm:mcp:read'])
const requestId = 'fa58fa40-4bcb-4616-9316-cb69463b3151'