fix(mcp): привязать OAuth-согласие и завершать чтение логов при обслуживании
This commit is contained in:
1 parent
78b268cc39
commit
6569dbfc60
6 files changed
+173
-8
No files matched your search
@@ -5,7 +5,7 @@ import type { ConnectionRow, McpPrincipal } from "./store.js"
|
|||||||
import type { RosSnapshot, RosStep } from "./resources.js"
|
import type { RosSnapshot, RosStep } from "./resources.js"
|
||||||
|
|
||||||
export interface ActionContext {
|
export interface ActionContext {
|
||||||
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal
|
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal; logsFollowMaintenance?: AbortSignal
|
||||||
/** Process-internal persistent controller identity; never parsed from tool JSON. */
|
/** Process-internal persistent controller identity; never parsed from tool JSON. */
|
||||||
persistentRoutingProfile?: { id: string; version: number }
|
persistentRoutingProfile?: { id: string; version: number }
|
||||||
/** Available only during target resolution, never retained by the read/prepare handler. */
|
/** Available only during target resolution, never retained by the read/prepare handler. */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { registerIpsecExistingCertificateExportActions } from "./ipsec-existing-
|
|||||||
import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js"
|
import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js"
|
||||||
import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js"
|
import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js"
|
||||||
import { isIP } from "node:net"
|
import { isIP } from "node:net"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
|
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
|
||||||
import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users"
|
import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users"
|
||||||
@@ -376,7 +377,10 @@ for (const context of [false, true]) add(context ? "logs.context" : "logs.get",
|
|||||||
})
|
})
|
||||||
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
|
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
|
||||||
read: async (input, context) => {
|
read: async (input, context) => {
|
||||||
|
const maintenance = context.logsFollowMaintenance
|
||||||
|
maintenance?.throwIfAborted()
|
||||||
const { server } = await routerClient(Number(input.serverId)); void server
|
const { server } = await routerClient(Number(input.serverId)); void server
|
||||||
|
maintenance?.throwIfAborted()
|
||||||
const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000
|
const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000
|
||||||
try {
|
try {
|
||||||
const filters = logFilters(input, context), generation = await accessGeneration()
|
const filters = logFilters(input, context), generation = await accessGeneration()
|
||||||
@@ -392,12 +396,15 @@ add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:se
|
|||||||
cursor = (await pool.query("SELECT COALESCE(max(id),0)::text AS cursor FROM router_log_entries WHERE server_id=$1", [input.serverId])).rows[0].cursor
|
cursor = (await pool.query("SELECT COALESCE(max(id),0)::text AS cursor FROM router_log_entries WHERE server_id=$1", [input.serverId])).rows[0].cursor
|
||||||
}
|
}
|
||||||
while (true) {
|
while (true) {
|
||||||
|
maintenance?.throwIfAborted()
|
||||||
const current = await validatePrincipal(context.principal)
|
const current = await validatePrincipal(context.principal)
|
||||||
|
maintenance?.throwIfAborted()
|
||||||
if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409)
|
if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409)
|
||||||
const batch = await readLogStream(filters, cursor)
|
const batch = await readLogStream(filters, cursor)
|
||||||
|
maintenance?.throwIfAborted()
|
||||||
cursor = batch.cursor
|
cursor = batch.cursor
|
||||||
if (batch.entries.length || !batch.enabled || Date.now() >= until || context.signal?.aborted) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) }
|
if (batch.entries.length || !batch.enabled || Date.now() >= until || context.signal?.aborted) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) }
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await sleep(1000, undefined, { signal: maintenance })
|
||||||
}
|
}
|
||||||
} finally { release() }
|
} finally { release() }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2889,6 +2889,52 @@ try {
|
|||||||
assert.ok(!result.isError)
|
assert.ok(!result.isError)
|
||||||
console.log("✓ Official SDK HTTP initialize, filtered tools/list and tools/call")
|
console.log("✓ Official SDK HTTP initialize, filtered tools/list and tools/call")
|
||||||
} finally { await client.close() }
|
} finally { await client.close() }
|
||||||
|
{
|
||||||
|
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({
|
||||||
|
...input, name: "Logs follow maintenance", permissions: ["mm:servers:read"],
|
||||||
|
scope: { serverIds: [1], operationIds: ["logs.follow"], allowGlobal: false },
|
||||||
|
}), actor)
|
||||||
|
const follower = new Client({ name: "logs-follow-maintenance", version: "1.0.0" })
|
||||||
|
const { sharedDatabaseGate } = await import("../../services/shared-database-gate.js")
|
||||||
|
const original = pool.query
|
||||||
|
let arrived!: () => void, release!: () => void
|
||||||
|
const reading = new Promise<void>(resolve => { arrived = resolve })
|
||||||
|
const held = new Promise<void>(resolve => { release = resolve })
|
||||||
|
try {
|
||||||
|
await follower.connect(new StreamableHTTPClientTransport(new URL(endpoint), { requestInit: { headers: { Authorization: `Bearer ${access.token}` } } }))
|
||||||
|
pool.query = (async (...args: Parameters<typeof original>) => {
|
||||||
|
const text = typeof args[0] === "string" ? args[0] : args[0]?.text
|
||||||
|
const result = await Reflect.apply(original, pool, args)
|
||||||
|
if (typeof text === "string" && text.startsWith("SELECT GREATEST(COALESCE(max(id),0)")) {
|
||||||
|
arrived()
|
||||||
|
await held
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}) as typeof original
|
||||||
|
const following = follower.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 30 } })
|
||||||
|
try {
|
||||||
|
const observation = new AbortController()
|
||||||
|
try {
|
||||||
|
await Promise.race([reading, following.then(() => { throw new Error("Follow finished before its stream query") }),
|
||||||
|
delay(5000, undefined, { signal: observation.signal }).then(() => { throw new Error("Follow stream query was not observed") })])
|
||||||
|
} finally { observation.abort() }
|
||||||
|
let exclusiveEntered = false
|
||||||
|
const maintenance = sharedDatabaseGate.exclusive(async () => { exclusiveEntered = true }, 5000)
|
||||||
|
try {
|
||||||
|
await delay(25)
|
||||||
|
assert.equal(exclusiveEntered, false, "maintenance must wait for the pending database read")
|
||||||
|
} finally {
|
||||||
|
release()
|
||||||
|
await maintenance
|
||||||
|
}
|
||||||
|
const result = await following
|
||||||
|
assert.equal(result.isError, true)
|
||||||
|
assert.match(JSON.stringify(result), /RESET_REQUIRED/)
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /"cursor"/)
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [access.connection.id])).rows[0].count, 0)
|
||||||
|
} finally { release(); await following.catch(() => undefined) }
|
||||||
|
} finally { pool.query = original; await follower.close() }
|
||||||
|
}
|
||||||
const stdio = new Client({ name: "stdio-integration", version: "1.0.0" })
|
const stdio = new Client({ name: "stdio-integration", version: "1.0.0" })
|
||||||
try {
|
try {
|
||||||
await stdio.connect(new StdioClientTransport({ command: process.execPath, args: ["--import", "tsx", fileURLToPath(new URL("../../scripts/mcp-stdio.ts", import.meta.url))], env: { MCP_URL: endpoint, MCP_TOKEN: rotated.token }, stderr: "pipe" }))
|
await stdio.connect(new StdioClientTransport({ command: process.execPath, args: ["--import", "tsx", fileURLToPath(new URL("../../scripts/mcp-stdio.ts", import.meta.url))], env: { MCP_URL: endpoint, MCP_TOKEN: rotated.token }, stderr: "pipe" }))
|
||||||
|
|||||||
@@ -112,12 +112,21 @@ async function completeRequest(id: string, started: number, result: unknown, err
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
export async function invokeMcpTool(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }): Promise<unknown> {
|
export async function invokeMcpTool(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }): Promise<unknown> {
|
||||||
|
if (toolName === "mm_logs_follow") {
|
||||||
|
const maintenance = new AbortController()
|
||||||
|
return sharedDatabaseGate.request(
|
||||||
|
() => invokeMcpToolInner(principal, toolName, raw, signal, internal, maintenance.signal),
|
||||||
|
() => maintenance.abort(new McpError("RESET_REQUIRED", "База данных перешла в режим обслуживания; начните новое чтение", 409)),
|
||||||
|
)
|
||||||
|
}
|
||||||
return sharedDatabaseGate.request(() => invokeMcpToolInner(principal, toolName, raw, signal, internal))
|
return sharedDatabaseGate.request(() => invokeMcpToolInner(principal, toolName, raw, signal, internal))
|
||||||
}
|
}
|
||||||
async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }): Promise<unknown> {
|
async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }, logsFollowMaintenance?: AbortSignal): Promise<unknown> {
|
||||||
const action = findAction(toolName), input = action.schema.parse(raw) as Record<string, unknown>, started = Date.now()
|
const action = findAction(toolName), input = action.schema.parse(raw) as Record<string, unknown>, started = Date.now()
|
||||||
const call = await reserveCall(principal, toolName, input, signal)
|
const call = await reserveCall(principal, toolName, input, signal)
|
||||||
try {
|
try {
|
||||||
|
logsFollowMaintenance?.throwIfAborted()
|
||||||
|
if (logsFollowMaintenance) call.context.logsFollowMaintenance = logsFollowMaintenance
|
||||||
if (internal) {
|
if (internal) {
|
||||||
if (action.operation.id !== "routing.community.switch") throw new McpError("INVALID_INPUT", "Контекст профиля не относится к этой операции", 400)
|
if (action.operation.id !== "routing.community.switch") throw new McpError("INVALID_INPUT", "Контекст профиля не относится к этой операции", 400)
|
||||||
call.context.persistentRoutingProfile = z.object({ id: z.string().uuid(), version: z.number().int().positive().safe() }).strict().parse(internal.persistentRoutingProfile)
|
call.context.persistentRoutingProfile = z.object({ id: z.string().uuid(), version: z.number().int().positive().safe() }).strict().parse(internal.persistentRoutingProfile)
|
||||||
@@ -173,12 +182,19 @@ async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw
|
|||||||
return { planId: id, expiresAt: expiresAt.toISOString(), state: decision.decision === "approval-required" ? "awaiting-approval" : "draft", decision, changes: redact(publicMcpChangeEvidence(action.operation.id, evidence)), ...(vrfTicket ? { vrfTicket } : {}), executionAvailable: true }
|
return { planId: id, expiresAt: expiresAt.toISOString(), state: decision.decision === "approval-required" ? "awaiting-approval" : "draft", decision, changes: redact(publicMcpChangeEvidence(action.operation.id, evidence)), ...(vrfTicket ? { vrfTicket } : {}), executionAvailable: true }
|
||||||
})
|
})
|
||||||
} else throw new McpError("UNSUPPORTED_CAPABILITY", "Нет обработчика операции", 409)
|
} else throw new McpError("UNSUPPORTED_CAPABILITY", "Нет обработчика операции", 409)
|
||||||
|
logsFollowMaintenance?.throwIfAborted()
|
||||||
const current = await effectiveMcpConnection(principal)
|
const current = await effectiveMcpConnection(principal)
|
||||||
|
logsFollowMaintenance?.throwIfAborted()
|
||||||
if (fingerprint(current.policy) !== fingerprint(call.context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(call.context.connection.owner_permissions)) throw new McpError("STATE_CONFLICT", "Доступ изменился во время вызова; результат не выдан", 409)
|
if (fingerprint(current.policy) !== fingerprint(call.context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(call.context.connection.owner_permissions)) throw new McpError("STATE_CONFLICT", "Доступ изменился во время вызова; результат не выдан", 409)
|
||||||
const output = redact(result)
|
const output = redact(result)
|
||||||
await completeRequest(call.requestId, started, output)
|
await completeRequest(call.requestId, started, output)
|
||||||
|
logsFollowMaintenance?.throwIfAborted()
|
||||||
return output
|
return output
|
||||||
} catch (error) { await completeRequest(call.requestId, started, null, error); throw error }
|
} catch (error) {
|
||||||
|
const failure = logsFollowMaintenance?.aborted ? logsFollowMaintenance.reason : error
|
||||||
|
await completeRequest(call.requestId, started, null, failure)
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPlan(id: string, client: Pick<PoolClient, "query"> = pool): Promise<StoredPlan> {
|
export async function loadPlan(id: string, client: Pick<PoolClient, "query"> = pool): Promise<StoredPlan> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { useTable, type ColumnDef } from "@tanstack/react-table"
|
import { useTable, type ColumnDef } from "@tanstack/react-table"
|
||||||
import { BotIcon, CopyIcon, PlusIcon, RefreshCwIcon, ShieldIcon, StopCircleIcon } from "lucide-react"
|
import { BotIcon, CopyIcon, PlusIcon, RefreshCwIcon, ShieldIcon, StopCircleIcon } from "lucide-react"
|
||||||
@@ -300,8 +300,44 @@ function RestCoverage({ api }: { api: McpApi }) {
|
|||||||
function Consent({ api, id }: { api: McpApi; id: string }) {
|
function Consent({ api, id }: { api: McpApi; id: string }) {
|
||||||
const request = useQuery(useCallback(signal => api.consentRequest(id, signal), [api, id])), connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
const request = useQuery(useCallback(signal => api.consentRequest(id, signal), [api, id])), connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||||
const [selected, setSelected] = useState("")
|
const [selected, setSelected] = useState("")
|
||||||
const confirm = async (approve: boolean) => { try { const result = await api.consent(id, selected, approve); window.location.assign(result.redirectUrl) } catch (error) { toast.error(errorText(error)) } }
|
const [busy, setBusy] = useState(false)
|
||||||
return <Panel title="Разрешение OAuth-доступа" description="Выберите подключение с нужными ограничениями; клиент получит только его доступ"><ErrorMessage message={request.error || connections.error} /><p>{request.data?.name} запрашивает доступ к MikrotikManager.</p><p className="break-all text-sm text-muted-foreground">Возврат: {request.data?.redirect_uri}</p><NativeSelect label="Подключение" value={selected} onChange={setSelected}><option value="">Выберите ограниченный доступ</option>{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active").map(connection => <option key={connection.id} value={connection.id}>{connection.name} · {modeLabels[connection.policy.mode]}</option>)}</NativeSelect><div className="flex gap-2"><Button disabled={!selected} onClick={() => { void confirm(true) }}>Разрешить</Button><Button variant="outline" disabled={!selected} onClick={() => { void confirm(false) }}>Отклонить</Button></div></Panel>
|
const submitting = useRef(false)
|
||||||
|
const mounted = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true
|
||||||
|
return () => { mounted.current = false }
|
||||||
|
}, [])
|
||||||
|
const currentRequest = !request.error && request.data?.id === id ? request.data : null
|
||||||
|
const ownerId = getClaims()?.sub
|
||||||
|
const eligible = connections.error ? [] : connections.data?.connections.filter(connection => connection.ownerId === ownerId && connection.policy.status === "active") ?? []
|
||||||
|
const canSubmit = currentRequest !== null && eligible.some(connection => connection.id === selected)
|
||||||
|
const isCurrentLocation = () => mounted.current && new URLSearchParams(window.location.search).get("oauthRequest") === id
|
||||||
|
const confirm = async (approve: boolean) => {
|
||||||
|
if (!canSubmit || submitting.current || !isCurrentLocation()) return
|
||||||
|
submitting.current = true
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await api.consent(id, selected, approve)
|
||||||
|
if (isCurrentLocation()) window.location.assign(result.redirectUrl)
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted.current) return
|
||||||
|
submitting.current = false
|
||||||
|
setBusy(false)
|
||||||
|
if (isCurrentLocation()) toast.error(errorText(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <Panel title="Разрешение OAuth-доступа" description="Выберите подключение с нужными ограничениями; клиент получит только его доступ">
|
||||||
|
<ErrorMessage message={request.error || connections.error} />
|
||||||
|
{currentRequest && <><p>{currentRequest.name} запрашивает доступ к MikrotikManager.</p><p className="break-all text-sm text-muted-foreground">Возврат: {currentRequest.redirect_uri}</p></>}
|
||||||
|
<NativeSelect label="Подключение" value={selected} onChange={setSelected}>
|
||||||
|
<option value="">Выберите ограниченный доступ</option>
|
||||||
|
{eligible.map(connection => <option key={connection.id} value={connection.id}>{connection.name} · {modeLabels[connection.policy.mode]}</option>)}
|
||||||
|
</NativeSelect>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button disabled={!canSubmit || busy} onClick={() => { void confirm(true) }}>Разрешить</Button>
|
||||||
|
<Button variant="outline" disabled={!canSubmit || busy} onClick={() => { void confirm(false) }}>Отклонить</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
}
|
}
|
||||||
export function McpWorkspace() {
|
export function McpWorkspace() {
|
||||||
const { backendUrl, mode } = useDataSource()
|
const { backendUrl, mode } = useDataSource()
|
||||||
@@ -309,10 +345,11 @@ export function McpWorkspace() {
|
|||||||
}
|
}
|
||||||
function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: boolean }) {
|
function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: boolean }) {
|
||||||
const params = useSearchParams(), initial = params.get("tab") as Tab | null
|
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 [tab, setTab] = useState<Tab>(initial && initial in tabs ? initial : "overview")
|
||||||
const api = useMemo(() => mcpApi(backendUrl), [backendUrl])
|
const api = useMemo(() => mcpApi(backendUrl), [backendUrl])
|
||||||
const [operations, setOperations] = useState<McpOperationDescriptor[]>([]), [error, setError] = useState("")
|
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])
|
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) }
|
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} />{params.get("oauthRequest") && <Consent api={api} id={params.get("oauthRequest")!} />}<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} /></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} /></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></>
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,65 @@ async function setup(page: Page, permissions: string[]) {
|
|||||||
return { mutations }
|
return { mutations }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('synthetic: bind OAuth consent to the displayed request and submit only once', async ({ page }) => {
|
||||||
|
await setup(page, ['mm:mcp:read'])
|
||||||
|
const firstId = 'fa58fa40-4bcb-4616-9316-cb69463b3151'
|
||||||
|
const secondId = 'fa58fa40-4bcb-4616-9316-cb69463b3152'
|
||||||
|
const headers = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET,POST,OPTIONS', 'access-control-allow-headers': 'Authorization,Content-Type' }
|
||||||
|
const first = { id: firstId, name: 'OAuth A', redirect_uri: 'https://a.example.test/callback', scope: 'mcp' }
|
||||||
|
const second = { id: secondId, name: 'OAuth B', redirect_uri: 'https://b.example.test/callback', scope: 'mcp' }
|
||||||
|
let releaseSecond!: () => void
|
||||||
|
const secondPending = new Promise<void>(resolve => { releaseSecond = resolve })
|
||||||
|
await page.route('**/api/mcp/oauth/requests/*', async route => {
|
||||||
|
if (route.request().method() === 'OPTIONS') return route.fulfill({ status: 204, headers })
|
||||||
|
const isSecond = route.request().url().endsWith(secondId)
|
||||||
|
if (isSecond) await secondPending
|
||||||
|
await route.fulfill({ headers, json: isSecond ? second : first })
|
||||||
|
})
|
||||||
|
let releasePost!: () => void
|
||||||
|
const postPending = new Promise<void>(resolve => { releasePost = resolve })
|
||||||
|
const decisions: unknown[] = []
|
||||||
|
await page.route('**/api/mcp/oauth/consent', async route => {
|
||||||
|
if (route.request().method() === 'OPTIONS') return route.fulfill({ status: 204, headers })
|
||||||
|
decisions.push(route.request().postDataJSON())
|
||||||
|
await postPending
|
||||||
|
await route.fulfill({ status: 409, headers, json: { message: 'Синтетический отказ' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto('/mcp?oauthRequest=' + firstId)
|
||||||
|
const connection = page.getByLabel('Подключение', { exact: true })
|
||||||
|
const approve = page.getByRole('button', { name: 'Разрешить', exact: true })
|
||||||
|
const deny = page.getByRole('button', { name: 'Отклонить', exact: true })
|
||||||
|
await expect(page.getByText(first.redirect_uri, { exact: false })).toBeVisible()
|
||||||
|
await connection.selectOption(id)
|
||||||
|
await expect(approve).toBeEnabled()
|
||||||
|
const secondRequest = page.waitForRequest(request => request.method() === 'GET' && request.url().endsWith('/oauth/requests/' + secondId))
|
||||||
|
await page.evaluate(id => window.history.pushState(null, '', '/mcp?oauthRequest=' + id), secondId)
|
||||||
|
await secondRequest
|
||||||
|
await expect(page.getByText(first.redirect_uri, { exact: false })).toHaveCount(0)
|
||||||
|
await expect(connection).toHaveValue('')
|
||||||
|
await expect(approve).toBeDisabled()
|
||||||
|
await expect(deny).toBeDisabled()
|
||||||
|
expect(decisions).toEqual([])
|
||||||
|
|
||||||
|
releaseSecond()
|
||||||
|
await expect(page.getByText(second.redirect_uri, { exact: false })).toBeVisible()
|
||||||
|
await connection.selectOption(id)
|
||||||
|
await expect(approve).toBeEnabled()
|
||||||
|
await approve.evaluate((button, denyLabel) => {
|
||||||
|
const approve = button as HTMLButtonElement
|
||||||
|
const deny = [...document.querySelectorAll('button')].find(node => node.textContent === denyLabel)
|
||||||
|
approve.click()
|
||||||
|
deny?.click()
|
||||||
|
}, 'Отклонить')
|
||||||
|
await expect.poll(() => decisions).toEqual([{ requestId: secondId, connectionId: id, approve: true }])
|
||||||
|
await expect(approve).toBeDisabled()
|
||||||
|
await expect(deny).toBeDisabled()
|
||||||
|
releasePost()
|
||||||
|
await expect(approve).toBeEnabled()
|
||||||
|
expect(decisions).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
test('synthetic: issue only selected server/read permission and hide one-time token', async ({ page }) => {
|
test('synthetic: issue only selected server/read permission and hide one-time token', async ({ page }) => {
|
||||||
const state = await setup(page, ['mm:mcp:admin', 'mm:servers:read'])
|
const state = await setup(page, ['mm:mcp:admin', 'mm:servers:read'])
|
||||||
await page.goto('/mcp?tab=connections')
|
await page.goto('/mcp?tab=connections')
|
||||||
|
|||||||
Reference in new issue
Block a user