From 0b028cc75a1bb080e03606fd132b12d11a8dbdc4 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:35:39 +0800 Subject: [PATCH] feat(mcp): refresh provider candidates without switching (#8487) * fix(mcp): report provider candidate observation scope * feat(mcp): refresh provider candidates without switching * docs: clarify candidate inspection ownership * fix(mcp): clarify provider reselection on reopen * test(mcp): bound candidate refresh lifecycle gates * test(mcp): keep temporary fixture cleanup with its owner --- docs/reference/mcp-contracts.md | 16 +- scripts/verify-real-kuna.mjs | 8 +- src/application/binary/BinarySession.ts | 18 +- .../BinarySessionBundleResources.test.ts | 1 + src/application/binary/BinarySessionPort.ts | 1 + src/application/binary/BinarySessionStatus.ts | 16 +- .../binary/SessionProviderRouter.ts | 19 +- src/contracts/sessionStatusContract.ts | 6 + src/contracts/sessionToolContracts.ts | 2 +- src/contracts/toolOutputSchemaPrimitives.ts | 5 + src/server/registerSessionStatusTool.ts | 1 + .../boundary/mcp/providerSelectionMcp.test.ts | 433 ++++++++++++++++++ 12 files changed, 516 insertions(+), 10 deletions(-) diff --git a/docs/reference/mcp-contracts.md b/docs/reference/mcp-contracts.md index afd1b039d8..19413084d1 100644 --- a/docs/reference/mcp-contracts.md +++ b/docs/reference/mcp-contracts.md @@ -1066,8 +1066,20 @@ limitations. An unavailable group carries `remediation` when its readiness owner supplied a repair instruction; `tool_availability` uses that instruction rather than the failure description. -`binary_session.analysis_provider_candidates` is authoritative for deep-engine -discovery. Each candidate's capability sets, returned with complete detail, +`binary_session.analysis_provider_candidates` reports deep-engine discovery. +`analysis_provider_candidates_observed` is `target_selection` when those facts +were retained from the active target's selection, `status_request` when the +status request sought candidate observations, and `not_applicable` for resource-only +sessions. Selection-time readiness may have changed since the target was opened. +Set `refresh_candidates: true` to inspect candidate readiness and target support +again without replacing the active binding or engine. That does not resolve a +new analysis profile or start a provider client. Later transitions and analysis +calls wait for the inspection to finish, including cleanup. Current-route capabilities +remain those of the bound session. Candidate statuses can still be unknown when +an inspection cannot complete. +`open_binary` with an explicit `provider_id` performs a new selection. Reopening +the same target without `provider_id` reuses its current route. A changed route +can retire the previous engine and its ephemeral edits. Each candidate's capability sets, returned with complete detail, describe that possible provider; they do not select it. The top-level `capabilities` describe the current route, while `analysis_provider_binding` identifies the concrete selected route. These diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index d07970eefd..0e7bfe1243 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -286,9 +286,13 @@ const openServer = async (provider, kunaSettings = {}) => { path: provider.path, provider_id: provider.id, }); + const session = (await call("binary_session")).value; + assert.equal( + session.analysis_provider_candidates_observed, + "target_selection", + ); if (provider.id === "kuna") { - const status = (await call("binary_session")).value; - const binding = status.analysis_provider_binding; + const binding = session.analysis_provider_binding; const verification = binding.release_verification; assert.equal(verification.basis, "reported_version"); assert.equal(verification.verified_version, KUNA_VERIFIED_VERSION); diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 404b3bb3bc..a30b6c0e67 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -1304,8 +1304,20 @@ export class BinarySession options: { readonly signal?: AbortSignal; readonly detail?: BinarySessionDetail; + readonly refreshCandidates?: boolean; } = {}, ): Promise { + return options.refreshCandidates === true + ? this.#serialize(() => this.#status(options), { abortPrompts: false }) + : this.#status(options); + } + + /** Keep an explicit candidate refresh ordered with target transitions. */ + #status(options: { + readonly signal?: AbortSignal; + readonly detail?: BinarySessionDetail; + readonly refreshCandidates?: boolean; + }): Promise { const resourceRun = this.#activeBundleResources; const run = this.#active ?? @@ -1606,9 +1618,11 @@ export class BinarySession #serialize( operation: (admittedThrough: number) => Promise, + options: { readonly abortPrompts?: boolean } = {}, ): Promise { - for (const controller of this.#promptCompletionControllers) - controller.abort(); + if (options.abortPrompts !== false) + for (const controller of this.#promptCompletionControllers) + controller.abort(); this.#transitionPending += 1; // Later calls wait for this transition and must not be included in its drain. const admittedThrough = this.#transitionGeneration++; diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts index 553705d522..d1d89dd7a4 100644 --- a/src/application/binary/BinarySessionBundleResources.test.ts +++ b/src/application/binary/BinarySessionBundleResources.test.ts @@ -166,6 +166,7 @@ it("opens a resource-only bundle without executable identity and keeps snapshot expect(await session.status()).toMatchObject({ open: true, session_kind: "bundle_resources", + analysis_provider_candidates_observed: "not_applicable", resource_root: { canonical_path: opened.value.canonicalPath }, }); const snapshot = session.exportAnalysisSnapshot(); diff --git a/src/application/binary/BinarySessionPort.ts b/src/application/binary/BinarySessionPort.ts index 5137de3fe4..1667c0f8ad 100644 --- a/src/application/binary/BinarySessionPort.ts +++ b/src/application/binary/BinarySessionPort.ts @@ -140,6 +140,7 @@ export interface BinarySessionPort status(options?: { readonly signal?: AbortSignal; readonly detail?: BinarySessionDetail; + readonly refreshCandidates?: boolean; }): Promise; activeTarget(): BinaryTarget | undefined; /** Read a snapshot with failed descriptors retained for session cleanup retry. */ diff --git a/src/application/binary/BinarySessionStatus.ts b/src/application/binary/BinarySessionStatus.ts index a3b69a6ba2..8f54e1aa22 100644 --- a/src/application/binary/BinarySessionStatus.ts +++ b/src/application/binary/BinarySessionStatus.ts @@ -47,6 +47,7 @@ interface BinarySessionStatusInput { readonly cleanupRetry?: AnalysisCleanupRetry; readonly detail?: BinarySessionDetail; readonly signal?: AbortSignal; + readonly refreshCandidates?: boolean; } /** Project internal provider routing state into the caller-visible session status. */ @@ -66,6 +67,7 @@ export const binarySessionStatus = async ({ cleanupRetry, detail = "complete", signal, + refreshCandidates = false, }: BinarySessionStatusInput): Promise => { const routeCapabilities = ( bundleResourceRoot === undefined @@ -119,7 +121,13 @@ export const binarySessionStatus = async ({ const analysisProviderCandidates = bundleResourceRoot !== undefined ? [] - : (await router.candidateStatuses(route, signal)).map((candidate) => + : ( + await router.candidateStatuses(route, { + ...(signal === undefined ? {} : { signal }), + ...(target === undefined ? {} : { target }), + refresh: refreshCandidates, + }) + ).map((candidate) => detail === "summary" ? { provider: providerSummary(candidate.provider), @@ -153,6 +161,12 @@ export const binarySessionStatus = async ({ capabilities, analysis_provider_binding: analysisProviderBinding, analysis_provider_candidates: analysisProviderCandidates, + analysis_provider_candidates_observed: + bundleResourceRoot !== undefined + ? "not_applicable" + : route.selection === undefined || refreshCandidates + ? "status_request" + : "target_selection", analysis_run: runId === undefined ? null diff --git a/src/application/binary/SessionProviderRouter.ts b/src/application/binary/SessionProviderRouter.ts index 3eb47cf518..33f25accd1 100644 --- a/src/application/binary/SessionProviderRouter.ts +++ b/src/application/binary/SessionProviderRouter.ts @@ -129,11 +129,26 @@ export class SessionProviderRouter { ); } - /** Candidate status authoritative for the active or target-free route. */ + /** Preserve selection observations unless a caller requests a fresh inspection. */ async candidateStatuses( route: SessionProviderRoute, - signal?: AbortSignal, + options: { + readonly signal?: AbortSignal; + readonly target?: BinaryTarget; + readonly refresh?: boolean; + } = {}, ): Promise { + const { signal, target, refresh = false } = options; + if (refresh && route.selection !== undefined) { + const current = await this.registry.candidates( + target, + signal === undefined ? {} : { signal }, + ); + return current.map((candidate) => ({ + ...candidate, + selected: candidate.provider.id === route.binding?.identity.id, + })); + } return ( route.selection?.candidates ?? this.registry.candidates( diff --git a/src/contracts/sessionStatusContract.ts b/src/contracts/sessionStatusContract.ts index 41659fec03..a188b53a08 100644 --- a/src/contracts/sessionStatusContract.ts +++ b/src/contracts/sessionStatusContract.ts @@ -27,6 +27,12 @@ export const binarySessionInputSchema = z.strictObject({ .describe( "Start with summary (default): it keeps open state, the binding, current-route capabilities (including selected Ghidra checkpoint mode), each provider candidate's availability and target-support status, code and reason, and every tool's availability, reason and remediation; omitted_fields lists what it leaves out. Request complete only when you need unavailable-candidate remediation, provider diagnostics, candidate capabilities or static tool surface, effect and annotation flags.", ), + refresh_candidates: z + .boolean() + .default(false) + .describe( + "Inspect current provider candidates for the active target without replacing or restarting the selected provider. Later transitions and analysis calls wait for inspection finalization. Omitted or false reports the retained selection observations.", + ), expected_package_version: z.string().min(1).optional(), expected_catalog_digest: z .string() diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 64f54b8e0b..2232aca70e 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -88,7 +88,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "binary_session", - 'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. A selected provider may report release_verification under its binding; omission means not reported, and reported-version verification does not authenticate executable bytes. Start with the default summary: after selecting Ghidra, capabilities[].shared_limitations already reports enabled checkpoint or ephemeral mode. Request detail: "complete" only for provider diagnostics, candidate capabilities or static tool flags; omitted_fields lists what summary leaves out.', + 'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. analysis_provider_candidates_observed distinguishes this status request from retained target-selection observations; selection-time readiness may have changed. Set refresh_candidates true to inspect current candidates without changing the active binding or engine. open_binary with an explicit provider_id reselects and may retire the previous engine and its ephemeral edits; reopening the same target without provider_id reuses its current route. A selected provider may report release_verification under its binding; omission means not reported, and reported-version verification does not authenticate executable bytes. Start with the default summary: after selecting Ghidra, capabilities[].shared_limitations already reports enabled checkpoint or ephemeral mode. Request detail: "complete" only for provider diagnostics, candidate capabilities or static tool flags; omitted_fields lists what summary leaves out.', binarySessionInputSchema, ), session( diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 41d562939a..c212cbe3c4 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -597,6 +597,11 @@ export const sessionProviderFacts = z analysis_run: analysisRun, analysis_activity: analysisActivity, provider_operation_health: providerOperationHealth, + analysis_provider_candidates_observed: z + .enum(["status_request", "target_selection", "not_applicable"]) + .describe( + "When provider candidate observations were collected: this status request, the retained target selection, or not applicable to a resource-only session.", + ), analysis_provider_binding: z .object({ provider: providerIdentity, diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index 663b9fdd8b..892e681ca0 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -103,6 +103,7 @@ export const registerSessionStatusTool = ( status = await session.status({ signal: context.mcpReq.signal, detail: input.detail, + refreshCandidates: input.refresh_candidates, }); } catch (cause) { if (!(cause instanceof AnalysisError)) throw cause; diff --git a/tests/boundary/mcp/providerSelectionMcp.test.ts b/tests/boundary/mcp/providerSelectionMcp.test.ts index 630af8fe3d..61a6ff4424 100644 --- a/tests/boundary/mcp/providerSelectionMcp.test.ts +++ b/tests/boundary/mcp/providerSelectionMcp.test.ts @@ -1290,6 +1290,439 @@ it("preserves a selected Ghidra project's read-only guidance when an edit is req expect(starts).toEqual([]); }); +it("labels unbound provider observations as selection-time after a repair", async () => { + const directory = await createTestTempDirectory("rea-candidate-observation-"); + const target = join(directory, "fixture.hop"); + await writeFile(target, "fixture"); + let available = false; + const base = candidate("alpha", []); + const session = composeBinarySession( + new AnalysisProviderRegistry([ + { + ...base, + inspectAvailability: () => + candidate("alpha", [], available).inspectAvailability(), + }, + ]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const mcp = new Client({ name: "candidate-scope", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server, { close: () => session.close() }); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + expect( + (await mcp.callTool({ name: "open_binary", arguments: { path: target } })) + .isError, + ).toBeFalsy(); + available = true; + const stale = toolContract("binary_session").outputSchema.parse( + (await mcp.callTool({ name: "binary_session", arguments: {} })) + .structuredContent, + ).result; + expect(stale.analysis_provider_binding).toBeNull(); + expect(stale.analysis_provider_candidates_observed).toBe("target_selection"); + expect(stale.analysis_provider_candidates[0]?.availability.status).toBe( + "unavailable", + ); + const inspected = toolContract("binary_session").outputSchema.parse( + ( + await mcp.callTool({ + name: "binary_session", + arguments: { refresh_candidates: true }, + }) + ).structuredContent, + ).result; + expect(inspected.analysis_provider_binding).toBeNull(); + expect(inspected.analysis_provider_candidates_observed).toBe( + "status_request", + ); + expect(inspected.analysis_provider_candidates[0]?.availability.status).toBe( + "available", + ); + const retained = toolContract("binary_session").outputSchema.parse( + (await mcp.callTool({ name: "binary_session", arguments: {} })) + .structuredContent, + ).result; + expect(retained.analysis_provider_candidates[0]?.availability.status).toBe( + "unavailable", + ); + expect( + (await mcp.callTool({ name: "open_binary", arguments: { path: target } })) + .isError, + ).toBeFalsy(); + const reused = toolContract("binary_session").outputSchema.parse( + (await mcp.callTool({ name: "binary_session", arguments: {} })) + .structuredContent, + ).result; + expect(reused.analysis_provider_binding).toBeNull(); + expect(reused.analysis_provider_candidates[0]?.availability.status).toBe( + "unavailable", + ); + expect( + ( + await mcp.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "alpha" }, + }) + ).isError, + ).toBeFalsy(); + const refreshed = toolContract("binary_session").outputSchema.parse( + (await mcp.callTool({ name: "binary_session", arguments: {} })) + .structuredContent, + ).result; + expect(refreshed.analysis_provider_candidates_observed).toBe( + "target_selection", + ); + expect(refreshed.analysis_provider_binding?.provider.id).toBe("alpha"); +}); + +it("preserves the bound client when a candidate refresh is cancelled during finalization", async () => { + const directory = await createTestTempDirectory( + "rea-candidate-refresh-cancel-", + ); + const target = join(directory, "fixture.hop"); + await writeFile(target, "fixture"); + const starts: string[] = []; + const base = candidate("alpha", starts); + let hold = false; + let enter!: () => void; + let finish!: () => void; + let finalized!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + const done = new Promise((resolve) => { + finalized = resolve; + }); + const session = composeBinarySession( + new AnalysisProviderRegistry([ + { + ...base, + inspectAvailability: async ({ + signal, + }: AnalysisProviderInspectionOptions = {}) => { + const result = await base.inspectAvailability(); + if (!hold) return result; + enter(); + await gate; + finalized(); + if (signal?.aborted) + throw new AnalysisCancelledError("binary_session", { + cause: signal.reason, + }); + return result; + }, + }, + ]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const mcp = new Client({ name: "candidate-refresh-cancel", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server, { close: () => session.close() }); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + let refreshing: ReturnType | undefined; + try { + expect( + ( + await mcp.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "alpha" }, + }) + ).isError, + ).toBeFalsy(); + expect( + (await mcp.callTool({ name: "address_name", arguments: {} })).isError, + ).toBeFalsy(); + expect(starts).toEqual(["alpha"]); + hold = true; + const controller = new AbortController(); + refreshing = mcp.callTool( + { name: "binary_session", arguments: { refresh_candidates: true } }, + { signal: controller.signal }, + ); + await awaitAcknowledgement(entered, refreshing, "candidate refresh"); + controller.abort(); + await expect(refreshing).rejects.toThrow(); + finish(); + await done; + hold = false; + expect( + structured(await mcp.callTool({ name: "binary_session", arguments: {} })) + .result, + ).toMatchObject({ + analysis_provider_binding: { provider: { id: "alpha" } }, + analysis_provider_candidates_observed: "target_selection", + }); + expect( + (await mcp.callTool({ name: "address_name", arguments: {} })).isError, + ).toBeFalsy(); + expect(starts).toEqual(["alpha"]); + } finally { + hold = false; + finish(); + await Promise.allSettled(refreshing === undefined ? [] : [refreshing]); + } +}); + +it("orders a provider switch after an in-flight candidate refresh", async () => { + const directory = await createTestTempDirectory( + "rea-candidate-refresh-switch-", + ); + const target = join(directory, "fixture.hop"); + await writeFile(target, "fixture"); + const starts: string[] = []; + const alpha = candidate("alpha", starts); + let hold = false; + let enter!: () => void; + let finish!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + const session = composeBinarySession( + new AnalysisProviderRegistry([ + { + ...alpha, + inspectAvailability: async () => { + if (hold) { + hold = false; + enter(); + await gate; + } + return alpha.inspectAvailability(); + }, + }, + candidate("beta", starts), + ]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const mcp = new Client({ name: "refresh-switch-race", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server, { close: () => session.close() }); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + let refreshing: ReturnType | undefined; + let switching: ReturnType | undefined; + try { + expect( + ( + await mcp.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "alpha" }, + }) + ).isError, + ).toBeFalsy(); + hold = true; + refreshing = mcp.callTool({ + name: "binary_session", + arguments: { refresh_candidates: true }, + }); + await awaitAcknowledgement(entered, refreshing, "candidate refresh"); + let switched = false; + switching = mcp + .callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "beta" }, + }) + .then((result) => { + switched = true; + return result; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(switched).toBe(false); + finish(); + const result = await refreshing; + expect(result.isError).toBeFalsy(); + expect(structured(result).result).toMatchObject({ + analysis_provider_binding: { provider: { id: "alpha" } }, + }); + expect((await switching).isError).toBeFalsy(); + expect( + structured(await mcp.callTool({ name: "binary_session", arguments: {} })) + .result, + ).toMatchObject({ + analysis_provider_binding: { provider: { id: "beta" } }, + }); + } finally { + finish(); + await Promise.allSettled( + [refreshing, switching].filter((operation) => operation !== undefined), + ); + } +}); + +it("does not cancel an already admitted prompt lookup for a candidate refresh", async () => { + const directory = await createTestTempDirectory("rea-refresh-prompt-"); + const target = join(directory, "fixture.hop"); + await writeFile(target, "fixture"); + const base = candidate("alpha", []); + let enter!: () => void; + let finish!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + const session = composeBinarySession( + new AnalysisProviderRegistry([ + { + ...base, + createClient: (selected, profile, context) => { + const client = base.createClient(selected, profile, context); + return { + ...client, + executeIfReady: async (operation, args, options) => { + enter(); + await gate; + if (options?.signal?.aborted) + throw new Error("Prompt lookup was cancelled"); + return client.execute(operation, args, options); + }, + }; + }, + }, + ]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const mcp = new Client({ name: "refresh-prompt", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server, { close: () => session.close() }); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + let lookup: ReturnType | undefined; + try { + expect( + ( + await mcp.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "alpha" }, + }) + ).isError, + ).toBeFalsy(); + expect( + (await mcp.callTool({ name: "address_name", arguments: {} })).isError, + ).toBeFalsy(); + lookup = session.executeIfProviderReady("address_name", {}); + await awaitAcknowledgement(entered, lookup, "prompt lookup"); + expect( + ( + await mcp.callTool({ + name: "binary_session", + arguments: { refresh_candidates: true }, + }) + ).isError, + ).toBeFalsy(); + finish(); + expect((await lookup)?.ok).toBe(true); + } finally { + finish(); + await Promise.allSettled(lookup === undefined ? [] : [lookup]); + } +}); + +it("retains failed candidate-refresh cleanup while preserving the selected binding", async () => { + const directory = await createTestTempDirectory( + "rea-candidate-refresh-cleanup-", + ); + const target = join(directory, "fixture.hop"); + await writeFile(target, "fixture"); + const starts: string[] = []; + const beta = candidate("beta", starts); + let fail = false; + let release = false; + const failure = new ProviderCleanupError("beta", ["beta-refresh-owner"], { + reason: "controlled cleanup failure", + }); + const session = composeBinarySession( + new AnalysisProviderRegistry([ + candidate("alpha", starts), + { + ...beta, + inspectAvailability: () => { + if (fail) throw failure; + return beta.inspectAvailability(); + }, + closeOwnedResources: async () => { + if (!fail) return ok(null); + if (!release) return err(failure); + failure.resolveCleanup(); + return ok(null); + }, + }, + ]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const mcp = new Client({ name: "candidate-refresh-cleanup", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server, { close: () => session.close() }); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + try { + expect( + ( + await mcp.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "alpha" }, + }) + ).isError, + ).toBeFalsy(); + fail = true; + const failed = parseMcpToolError( + await mcp.callTool({ + name: "binary_session", + arguments: { refresh_candidates: true }, + }), + ); + expect(failed.error).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: ["beta-refresh-owner"] }, + }); + const status = structured( + await mcp.callTool({ name: "binary_session", arguments: {} }), + ); + expect(status.result).toMatchObject({ + cleanup_pending: true, + analysis_provider_binding: { provider: { id: "alpha" } }, + }); + release = true; + expect( + (await mcp.callTool({ name: "close_binary", arguments: {} })).isError, + ).toBeFalsy(); + expect(failure.cleanupIncomplete).toBe(false); + } finally { + release = true; + await session.close(); + } +}); + it("retains attributed mixed profile cleanup through registered MCP calls and partial retry", async () => { const directory = await createTestTempDirectory( "rea-profile-completion-mcp-",