fix(mcp): привязать OAuth-согласие и завершать чтение логов при обслуживании

This commit is contained in:
Codex committed 2026-11-05 03:56:17 +07:00
1 parent 78b268cc39
commit 6569dbfc60
6 files changed
+173 -8

No files matched your search

+1 -1
View File
@@ -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. */
+8 -1
View File
@@ -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" }))
+18 -2
View File
@@ -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> {
+41 -4
View File
@@ -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></>
} }
+59
View File
@@ -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')