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"
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. */
+8 -1
View File
@@ -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() }
},
@@ -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<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" })
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" }))
+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> {
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<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 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<PoolClient, "query"> = pool): Promise<StoredPlan> {