From 6569dbfc60ca56995e9d1a72da18ddfb689f40fa Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 5 Nov 2026 03:56:17 +0700 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D1=8C=20OAuth-=D1=81=D0=BE=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D0=B8=D0=B5=20=D0=B8=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=88=D0=B0=D1=82=D1=8C=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2=20=D0=BF=D1=80=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=81=D0=BB=D1=83=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/mcp/action-types.ts | 2 +- backend/src/modules/mcp/actions.ts | 9 +++- backend/src/modules/mcp/integration.test.ts | 46 ++++++++++++++++ backend/src/modules/mcp/runtime.ts | 20 ++++++- components/mcp/mcp-workspace.tsx | 45 ++++++++++++++-- tests/e2e/mcp-auth-context.spec.ts | 59 +++++++++++++++++++++ 6 files changed, 173 insertions(+), 8 deletions(-) diff --git a/backend/src/modules/mcp/action-types.ts b/backend/src/modules/mcp/action-types.ts index 98e99cb7..9de83f70 100644 --- a/backend/src/modules/mcp/action-types.ts +++ b/backend/src/modules/mcp/action-types.ts @@ -5,7 +5,7 @@ import type { ConnectionRow, McpPrincipal } from "./store.js" import type { RosSnapshot, RosStep } from "./resources.js" 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. */ persistentRoutingProfile?: { id: string; version: number } /** Available only during target resolution, never retained by the read/prepare handler. */ diff --git a/backend/src/modules/mcp/actions.ts b/backend/src/modules/mcp/actions.ts index 0556ae93..af844cc9 100644 --- a/backend/src/modules/mcp/actions.ts +++ b/backend/src/modules/mcp/actions.ts @@ -9,6 +9,7 @@ import { registerIpsecExistingCertificateExportActions } from "./ipsec-existing- import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js" import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js" import { isIP } from "node:net" +import { setTimeout as sleep } from "node:timers/promises" import { z } from "zod" import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts" 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) }), { read: async (input, context) => { + const maintenance = context.logsFollowMaintenance + maintenance?.throwIfAborted() const { server } = await routerClient(Number(input.serverId)); void server + maintenance?.throwIfAborted() const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000 try { 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 } while (true) { + maintenance?.throwIfAborted() const current = await validatePrincipal(context.principal) + maintenance?.throwIfAborted() if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409) const batch = await readLogStream(filters, cursor) + maintenance?.throwIfAborted() 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 })) } - await new Promise(resolve => setTimeout(resolve, 1000)) + await sleep(1000, undefined, { signal: maintenance }) } } finally { release() } }, diff --git a/backend/src/modules/mcp/integration.test.ts b/backend/src/modules/mcp/integration.test.ts index 7b31447b..9db6bf4b 100644 --- a/backend/src/modules/mcp/integration.test.ts +++ b/backend/src/modules/mcp/integration.test.ts @@ -2889,6 +2889,52 @@ try { assert.ok(!result.isError) console.log("✓ Official SDK HTTP initialize, filtered tools/list and tools/call") } 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(resolve => { arrived = resolve }) + const held = new Promise(resolve => { release = resolve }) + try { + await follower.connect(new StreamableHTTPClientTransport(new URL(endpoint), { requestInit: { headers: { Authorization: `Bearer ${access.token}` } } })) + pool.query = (async (...args: Parameters) => { + 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" }) 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" })) diff --git a/backend/src/modules/mcp/runtime.ts b/backend/src/modules/mcp/runtime.ts index ea37e174..14198a0d 100644 --- a/backend/src/modules/mcp/runtime.ts +++ b/backend/src/modules/mcp/runtime.ts @@ -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 { + 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)) } -async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }): Promise { +async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }, logsFollowMaintenance?: AbortSignal): Promise { const action = findAction(toolName), input = action.schema.parse(raw) as Record, started = Date.now() const call = await reserveCall(principal, toolName, input, signal) try { + logsFollowMaintenance?.throwIfAborted() + if (logsFollowMaintenance) call.context.logsFollowMaintenance = logsFollowMaintenance if (internal) { 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) @@ -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 } }) } else throw new McpError("UNSUPPORTED_CAPABILITY", "Нет обработчика операции", 409) + logsFollowMaintenance?.throwIfAborted() 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) const output = redact(result) await completeRequest(call.requestId, started, output) + logsFollowMaintenance?.throwIfAborted() 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 = pool): Promise { diff --git a/components/mcp/mcp-workspace.tsx b/components/mcp/mcp-workspace.tsx index a2853a56..89df7606 100644 --- a/components/mcp/mcp-workspace.tsx +++ b/components/mcp/mcp-workspace.tsx @@ -1,6 +1,6 @@ "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 { useTable, type ColumnDef } from "@tanstack/react-table" 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 }) { const request = useQuery(useCallback(signal => api.consentRequest(id, signal), [api, id])), connections = useQuery(useCallback(signal => api.connections(signal), [api])) 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)) } } - return

{request.data?.name} запрашивает доступ к MikrotikManager.

Возврат: {request.data?.redirect_uri}

{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active").map(connection => )}
+ const [busy, setBusy] = useState(false) + 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 + + {currentRequest && <>

{currentRequest.name} запрашивает доступ к MikrotikManager.

Возврат: {currentRequest.redirect_uri}

} + + + {eligible.map(connection => )} + +
+ + +
+
} export function McpWorkspace() { const { backendUrl, mode } = useDataSource() @@ -309,10 +345,11 @@ export function McpWorkspace() { } function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: boolean }) { const params = useSearchParams(), initial = params.get("tab") as Tab | null + const oauthRequestId = params.get("oauthRequest") const [tab, setTab] = useState(initial && initial in tabs ? initial : "overview") const api = useMemo(() => mcpApi(backendUrl), [backendUrl]) const [operations, setOperations] = useState([]), [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 <>

MCP

Доступ нейросетей, разрешения и контроль изменений

{!live ?

Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.

: <>{params.get("oauthRequest") && }
{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => {title})}
{can("mm:mcp:admin") && }
{can("mm:mcp:admin") &&
{can("mm:settings:admin") && }
}
}
+ return <>

MCP

Доступ нейросетей, разрешения и контроль изменений

{!live ?

Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.

: <>{oauthRequestId && }
{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => {title})}
{can("mm:mcp:admin") && }
{can("mm:mcp:admin") &&
{can("mm:settings:admin") && }
}
}
} diff --git a/tests/e2e/mcp-auth-context.spec.ts b/tests/e2e/mcp-auth-context.spec.ts index 02b8c09d..78100aba 100644 --- a/tests/e2e/mcp-auth-context.spec.ts +++ b/tests/e2e/mcp-auth-context.spec.ts @@ -31,6 +31,65 @@ async function setup(page: Page, permissions: string[]) { 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(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(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 }) => { const state = await setup(page, ['mm:mcp:admin', 'mm:servers:read']) await page.goto('/mcp?tab=connections')