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"
|
||||
|
||||
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. */
|
||||
|
||||
@@ -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" }))
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user