diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 139c7d6f81..f1748e5a63 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -22,6 +22,7 @@ import type { ToolKind } from "../contracts/toolContractTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { isMachOFormat } from "../domain/binaryTargetTypes.js"; import type { ProviderAvailability } from "./AnalysisProvider.js"; +import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js"; import { TOOL_CONTRACTS } from "../contracts/toolContracts.js"; import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js"; import { @@ -56,7 +57,7 @@ export type AvailabilityPolicy = { readonly androidAnalysisAvailability?: ProviderAvailability; readonly adbAnalysisAvailability?: ProviderAvailability; readonly apktoolAnalysisAvailability?: ProviderAvailability; - readonly jebAnalysisAvailability?: ProviderAvailability; + readonly jebAnalysisAvailability?: JebAnalysisAvailability; readonly javascriptRecoveryEnabled?: boolean; readonly webModuleResolutionEnabled?: boolean; readonly browserObservationEnabled?: boolean; @@ -485,15 +486,22 @@ const workflowAvailabilityFor = ({ policy.apktoolAnalysisAvailability, "Set REA_APKTOOL_COMMAND to a caller-supplied Apktool executable or make Apktool 2.7.0 or newer available on PATH.", ); - if (kind === "jeb-provider") - return policy.jebAnalysisAvailability?.status === "available" - ? { reason: "available", remediation: null } - : { - reason: "provider_missing", - remediation: - policy.jebAnalysisAvailability?.reason ?? - "Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.", - }; + if (kind === "jeb-provider") { + const availability = policy.jebAnalysisAvailability; + if (availability?.status === "available") + return availability.supportedOperations.some( + (operation) => operation === name, + ) + ? { reason: "available", remediation: null } + : { + reason: "provider_unavailable", + remediation: `The running JEB endpoint does not advertise support for ${name}. Use a JEB client build that provides this operation.`, + }; + return ownerProbeAvailability( + availability, + "Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.", + ); + } const browser = browserProviderAvailability(name, kind, policy); if (browser !== null) return browser; if (name === "capture_process_scenario" && !policy.processCaptureEnabled) diff --git a/src/application/jeb/JebAnalysisPort.ts b/src/application/jeb/JebAnalysisPort.ts index 8b0498b93e..d6e00bd4cb 100644 --- a/src/application/jeb/JebAnalysisPort.ts +++ b/src/application/jeb/JebAnalysisPort.ts @@ -3,17 +3,24 @@ import type { ExecutionOptions, ProviderAvailability, } from "../AnalysisProvider.js"; -import type { JebRequest } from "../../domain/jeb/jebAnalysis.js"; +import type { JebOperation, JebRequest } from "../../domain/jeb/jebAnalysis.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { Result } from "../../domain/result.js"; +/** Live engine readiness and the operations advertised by that engine. */ +export type JebAnalysisAvailability = + | (Extract & { + readonly supportedOperations: readonly JebOperation[]; + }) + | Extract; + /** * JEB inspection boundary served by a caller-supplied running engine client. * Implementation details belong to the provider adapter. */ export interface JebAnalysisPort { /** Probe the running JEB client without opening a project. */ - inspectAvailability(signal?: AbortSignal): Promise; + inspectAvailability(signal?: AbortSignal): Promise; /** Release engine connection state; the JEB client itself keeps running. */ close(): Promise; execute( diff --git a/src/application/jeb/JebAnalysisService.test.ts b/src/application/jeb/JebAnalysisService.test.ts index 4f08f61abc..9f1e975e16 100644 --- a/src/application/jeb/JebAnalysisService.test.ts +++ b/src/application/jeb/JebAnalysisService.test.ts @@ -27,6 +27,12 @@ const fakePort = ( code: null, reason: null, diagnostics: {}, + supportedOperations: [ + "inspect_jeb_client", + "list_jeb_units", + "open_jeb_project", + "decompile_jeb_item", + ], }; }, async close() {}, diff --git a/src/jeb/JebMcpConnection.ts b/src/jeb/JebMcpConnection.ts index 137d0f32d7..afa177f5cf 100644 --- a/src/jeb/JebMcpConnection.ts +++ b/src/jeb/JebMcpConnection.ts @@ -1,5 +1,6 @@ import { Client, + DEFAULT_REQUEST_TIMEOUT_MSEC, StreamableHTTPClientTransport, } from "@modelcontextprotocol/client"; import { z } from "zod"; @@ -7,12 +8,26 @@ import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { AnalysisCancelledError, AnalysisProtocolError, + AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; +import type { JebOperation } from "../domain/jeb/jebAnalysis.js"; +import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; + +/** Upstream protocol names for the operations REA exposes. */ +export const JEB_ENGINE_TOOLS = { + inspect_jeb_client: "get_client_information", + open_jeb_project: "open_project", + list_jeb_units: "list_units", + decompile_jeb_item: "decompile_code_item", +} as const satisfies Readonly>; +const RELEVANT_ENGINE_TOOLS = new Set(Object.values(JEB_ENGINE_TOOLS)); /** Injectable MCP boundary used by the provider and conformance tests. */ export interface JebMcpConnection { - /** Connect lazily and return the advertised tool names. */ + /** Connect lazily and return advertised names used by REA. */ connect(signal?: AbortSignal): Promise; + /** Refresh support for REA operations from the live engine catalog. */ + listTools(signal?: AbortSignal): Promise; /** Invoke one JEB MCP tool and decode its single structured result. */ call( name: string, @@ -67,11 +82,15 @@ const isJebFailureEnvelope = (value: JsonValue): boolean => !Array.isArray(value) && value.success === false; -export type JebMcpConnectionFactory = (endpoint: URL) => JebMcpConnection; +export type JebMcpConnectionFactory = ( + endpoint: URL, + timeoutMs?: number, +) => JebMcpConnection; /** Serve JEB requests from a running engine client over streamable HTTP. */ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( endpoint, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MSEC, ) => { interface ConnectionState { readonly client: Client; @@ -164,29 +183,35 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( }; state = owner; const acquisition = (async () => { + const deadline = new ProviderStartupDeadline( + timeoutMs, + owner.signal.signal, + ); try { await owner.client.connect(owner.transport, { - signal: owner.signal.signal, + signal: deadline.signal, }); - const listing = await owner.client.listTools( - {}, - { signal: owner.signal.signal }, - ); - const tools = listing.tools.map((tool) => tool.name); + const tools = await readToolNames(owner.client, deadline.signal); owner.tools = tools; return tools; } catch (cause: unknown) { - owner.signal.abort(cause); + const failure = + deadline.interruption === "timeout" + ? new AnalysisTimeoutError("jeb_mcp_catalog", timeoutMs, { cause }) + : cause; + owner.signal.abort(failure); try { await dispose(owner); } catch (cleanupCause: unknown) { throw new AggregateError( - [cause, cleanupCause], + [failure, cleanupCause], "JEB MCP connection acquisition and cleanup failed", - { cause }, + { cause: failure }, ); } - throw cause; + throw failure; + } finally { + deadline.dispose(); } })(); owner.acquisition = acquisition; @@ -203,6 +228,30 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( return { connect, + async listTools(signal) { + const existing = state?.tools !== undefined; + // Initial acquisition owns its deadline and cleanup. A second timer + // could detach this waiter before that owner has disposed a failed client. + const tools = await connect(signal); + if (!existing) return tools; + const deadline = new ProviderStartupDeadline(timeoutMs, signal); + try { + const owner = state; + if (owner === undefined) + throw new AnalysisProtocolError("JEB MCP client failed to connect"); + const refreshed = await readToolNames(owner.client, deadline.signal); + owner.tools = refreshed; + return refreshed; + } catch (cause: unknown) { + if (deadline.interruption === "timeout") + throw new AnalysisTimeoutError("jeb_mcp_catalog", timeoutMs, { + cause, + }); + throw cause; + } finally { + deadline.dispose(); + } + }, async call(name, args, signal) { await connect(signal); const owner = state; @@ -235,3 +284,32 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( }, }; }; + +const readToolNames = async ( + client: Client, + signal: AbortSignal, +): Promise => { + const names = new Set(); + let cursor: string | undefined; + do { + signal.throwIfAborted(); + // listTools without a cursor aggregates and caches every page in SDK v2. + // This owner needs single pages to bound retained names and the whole walk. + const page = await client.request( + { + method: "tools/list", + ...(cursor === undefined ? {} : { params: { cursor } }), + }, + { signal }, + ); + signal.throwIfAborted(); + for (const tool of page.tools) + if (RELEVANT_ENGINE_TOOLS.has(tool.name)) names.add(tool.name); + if (names.size === RELEVANT_ENGINE_TOOLS.size) return [...names]; + const next = page.nextCursor; + if (next !== undefined && next === cursor) + throw new AnalysisProtocolError("JEB MCP repeated a tool catalog cursor"); + cursor = next; + } while (cursor !== undefined); + return [...names]; +}; diff --git a/src/jeb/JebProvider.test.ts b/src/jeb/JebProvider.test.ts index c6bd8c6ec2..83cee039dc 100644 --- a/src/jeb/JebProvider.test.ts +++ b/src/jeb/JebProvider.test.ts @@ -9,6 +9,7 @@ import { AnalysisCancelledError, } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { jebResultSchemas } from "../domain/jeb/jebAnalysis.js"; const scriptedConnection = ( responses: Readonly>, @@ -17,7 +18,10 @@ const scriptedConnection = ( return { calls, async connect() { - return []; + return Object.keys(responses); + }, + async listTools() { + return Object.keys(responses); }, async call(name, args) { calls.push([name, args]); @@ -37,7 +41,48 @@ const providerWith = (connection: JebMcpConnection) => connectionFactory: () => connection, }); -describe("JebProvider boundary", () => { +describe("JebProvider live availability", () => { + it("reports only the operations advertised by the live engine", async () => { + const connection = scriptedConnection({ + get_client_information: { + success: true, + version: "5.48.0", + gui_client: false, + }, + list_units: { success: true, units: [] }, + decompile_code_item: { success: true, text: "" }, + }); + const provider = providerWith(connection); + expect(await provider.inspectAvailability()).toMatchObject({ + status: "available", + supportedOperations: [ + "inspect_jeb_client", + "list_jeb_units", + "decompile_jeb_item", + ], + }); + expect(connection.calls).toEqual([["get_client_information", {}]]); + }); + + it("refreshes live operation support rather than reusing a readiness snapshot", async () => { + const connection = scriptedConnection({ + get_client_information: { success: true, version: "5.48.0" }, + }); + let tools = ["get_client_information", "open_project"]; + connection.listTools = async () => tools; + const provider = providerWith(connection); + expect(await provider.inspectAvailability()).toMatchObject({ + supportedOperations: ["inspect_jeb_client", "open_jeb_project"], + }); + tools = ["get_client_information"]; + expect(await provider.inspectAvailability()).toMatchObject({ + supportedOperations: ["inspect_jeb_client"], + }); + expect(connection.calls).toHaveLength(2); + }); +}); + +describe("JebProvider observations and failures", () => { it("normalizes client information and caches engine identity", async () => { const connection = scriptedConnection({ get_client_information: { @@ -52,12 +97,10 @@ describe("JebProvider boundary", () => { operation: "inspect_jeb_client", input: {}, }); - expect(result.ok).toBe(true); - if (!result.ok) return; - const normalized = result.value.result as { - readonly engine: { readonly version: string | null }; - readonly startup_ts: number | null; - }; + if (!result.ok) throw result.error; + const normalized = jebResultSchemas.inspect_jeb_client.parse( + result.value.result, + ); expect(normalized.engine.version).toBe("5.48.0.202610071539"); expect(normalized.startup_ts).toBe(1791623439); expect(result.value.provider.version).toBe("5.48.0.202610071539"); @@ -150,18 +193,18 @@ describe("JebProvider boundary", () => { operation: "list_jeb_units", input: { count: 3, index: 0 }, }); + if (!full.ok) throw full.error; expect( - full.ok && - (full.value.result as { coverage: string }).coverage === "partial", - ).toBe(true); + jebResultSchemas.list_jeb_units.parse(full.value.result).coverage, + ).toBe("partial"); const last = await provider.execute({ operation: "list_jeb_units", input: { count: 4, index: 0 }, }); + if (!last.ok) throw last.error; expect( - last.ok && - (last.value.result as { coverage: string }).coverage === "complete", - ).toBe(true); + jebResultSchemas.list_jeb_units.parse(last.value.result).coverage, + ).toBe("complete"); }); }); @@ -306,7 +349,10 @@ it("forwards in-flight cancellation to the MCP call", async () => { }); const provider = providerWith({ async connect() { - return []; + return ["get_client_information"]; + }, + async listTools() { + return ["get_client_information"]; }, async close() {}, async call(_name, _args, signal) { diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index bf62598a5d..3f37c4be30 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -1,9 +1,11 @@ import { z } from "zod"; -import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js"; +import type { + JebAnalysisPort, + JebAnalysisAvailability, +} from "../application/jeb/JebAnalysisPort.js"; import type { AnalysisExecution, ExecutionOptions, - ProviderAvailability, ProviderIdentity, } from "../application/AnalysisProvider.js"; import { createAnalysisExecution } from "../application/AnalysisProvider.js"; @@ -23,6 +25,7 @@ import { } from "./JebMcpEndpoint.js"; import { createStreamableHttpJebMcpConnection, + JEB_ENGINE_TOOLS, type JebMcpConnection, type JebMcpConnectionFactory, } from "./JebMcpConnection.js"; @@ -116,13 +119,17 @@ export class JebProvider implements JebAnalysisPort { async inspectAvailability( signal?: AbortSignal, - ): Promise { + ): Promise { try { + const tools = new Set(await this.#connection.listTools(signal)); await this.#refreshClientInformation(signal); return { status: "available", code: null, reason: null, + supportedOperations: ( + Object.keys(JEB_ENGINE_TOOLS) as JebOperation[] + ).filter((operation) => tools.has(JEB_ENGINE_TOOLS[operation])), diagnostics: { endpoint: this.#endpoint.toString(), engine_version: this.#identity.version, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 23e09e06b9..08b55b86f8 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -581,6 +581,7 @@ export const createServer = ( adbDevice.inspectAvailability(signal), apktoolAnalysisAvailability: (signal) => apktool.inspectAvailability(signal), + jebAnalysisAvailability: (signal) => jeb.inspectAvailability(signal), startedAt, }); } diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index 89420c8bb9..08587b0a7b 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -2,6 +2,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; import type { ProviderAvailability } from "../application/AnalysisProvider.js"; +import type { JebAnalysisAvailability } from "../application/jeb/JebAnalysisPort.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { buildCapabilityInventory, @@ -20,13 +21,8 @@ import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { err } from "../domain/result.js"; import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; -/** Inputs required to register the binary session status tool. */ -export interface SessionStatusToolOptions { - readonly server: EvidenceMcpServer; - readonly session: BinarySessionPort; - readonly contract: ReturnType>; - readonly startedAt: string; - readonly availabilityPolicy: () => AvailabilityPolicy; +/** Provider-owned live probes used by session discovery. */ +export interface SessionAvailabilityProbes { readonly androidAnalysisAvailability: ( signal: AbortSignal, ) => Promise; @@ -36,6 +32,18 @@ export interface SessionStatusToolOptions { readonly apktoolAnalysisAvailability?: ( signal: AbortSignal, ) => Promise; + readonly jebAnalysisAvailability?: ( + signal: AbortSignal, + ) => Promise; +} + +/** Inputs required to register the binary session status tool. */ +export interface SessionStatusToolOptions extends SessionAvailabilityProbes { + readonly server: EvidenceMcpServer; + readonly session: BinarySessionPort; + readonly contract: ReturnType>; + readonly startedAt: string; + readonly availabilityPolicy: () => AvailabilityPolicy; } /** Register the read-only provider and target status operation. */ @@ -51,6 +59,7 @@ export const registerSessionStatusTool = ( androidAnalysisAvailability, adbAnalysisAvailability, apktoolAnalysisAvailability, + jebAnalysisAvailability, } = options; server.registerTool( contract.name, @@ -63,15 +72,18 @@ export const registerSessionStatusTool = ( let androidAvailability: ProviderAvailability; let adbAvailability: ProviderAvailability | undefined; let apktoolAvailability: ProviderAvailability | undefined; + let jebAvailability: JebAnalysisAvailability | undefined; try { - const [android, adb, apktool] = await Promise.all([ + const [android, adb, apktool, jeb] = await Promise.all([ androidAnalysisAvailability(context.mcpReq.signal), adbAnalysisAvailability?.(context.mcpReq.signal) ?? undefined, apktoolAnalysisAvailability?.(context.mcpReq.signal) ?? undefined, + jebAnalysisAvailability?.(context.mcpReq.signal) ?? undefined, ] as const); androidAvailability = android; adbAvailability = adb; apktoolAvailability = apktool; + jebAvailability = jeb; } catch (cause) { if (!context.mcpReq.signal.aborted) throw cause; return server.delivery.toCallToolResult( @@ -92,6 +104,9 @@ export const registerSessionStatusTool = ( ...(apktoolAvailability === undefined ? {} : { apktoolAnalysisAvailability: apktoolAvailability }), + ...(jebAvailability === undefined + ? {} + : { jebAnalysisAvailability: jebAvailability }), }, clientFeatures, ); diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 2e2429cf79..58a47176e0 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -32,7 +32,10 @@ import { registerEvidenceTools, registerUnknownTools, } from "./registerSessionRecordTools.js"; -import { registerSessionStatusTool } from "./registerSessionStatusTool.js"; +import { + registerSessionStatusTool, + type SessionAvailabilityProbes, +} from "./registerSessionStatusTool.js"; import { sessionAvailabilityPolicy } from "./sessionAvailabilityPolicy.js"; import type { AvailabilityPolicy } from "../application/CapabilityInventory.js"; import { logToolExecution } from "./toolLogging.js"; @@ -143,7 +146,7 @@ const registerProcessTools = ({ ); }; -export interface LifecycleToolRegistration { +export interface LifecycleToolRegistration extends SessionAvailabilityProbes { readonly server: EvidenceMcpServer; readonly session: BinarySessionPort; readonly logger: Logger; @@ -152,15 +155,6 @@ export interface LifecycleToolRegistration { readonly statusContract: ReturnType>; readonly startedAt: string; readonly availabilityPolicy: () => AvailabilityPolicy; - readonly androidAnalysisAvailability: ( - signal: AbortSignal, - ) => Promise; - readonly adbAnalysisAvailability?: ( - signal: AbortSignal, - ) => Promise; - readonly apktoolAnalysisAvailability?: ( - signal: AbortSignal, - ) => Promise; readonly closeProcessResources: () => Promise>; } @@ -168,31 +162,11 @@ const registerLifecycleTools = ( registration: LifecycleToolRegistration, artifactResources: ArtifactResourceScope, ): void => { - const { - server, - session, - statusContract, - startedAt, - availabilityPolicy, - androidAnalysisAvailability, - adbAnalysisAvailability, - apktoolAnalysisAvailability, - } = registration; registerOpenLifecycleTool(registration, artifactResources); registerCloseLifecycleTool(registration); registerSessionStatusTool({ - server, - session, - contract: statusContract, - startedAt, - availabilityPolicy, - androidAnalysisAvailability, - ...(adbAnalysisAvailability === undefined - ? {} - : { adbAnalysisAvailability }), - ...(apktoolAnalysisAvailability === undefined - ? {} - : { apktoolAnalysisAvailability }), + ...registration, + contract: registration.statusContract, }); }; @@ -245,19 +219,10 @@ const registerOpenLifecycleTool = ( }; /** Register MCP-only target lifecycle operations on a long-lived session. */ -export interface SessionToolOptions { +export interface SessionToolOptions extends Partial { readonly artifactResources: ArtifactResourceScope; readonly startedAt?: string; readonly availabilityPolicy?: () => AvailabilityPolicy; - readonly androidAnalysisAvailability?: ( - signal: AbortSignal, - ) => Promise; - readonly adbAnalysisAvailability?: ( - signal: AbortSignal, - ) => Promise; - readonly apktoolAnalysisAvailability?: ( - signal: AbortSignal, - ) => Promise; } const registerContextTools = ( @@ -324,6 +289,7 @@ export const registerSessionTools = ( const processResources = new ProcessCaptureResourceScope(); registerLifecycleTools( { + ...options, server, session, logger, @@ -337,12 +303,6 @@ export const registerSessionTools = ( ), androidAnalysisAvailability: options.androidAnalysisAvailability ?? missingAndroidAvailability, - ...(options.adbAnalysisAvailability === undefined - ? {} - : { adbAnalysisAvailability: options.adbAnalysisAvailability }), - ...(options.apktoolAnalysisAvailability === undefined - ? {} - : { apktoolAnalysisAvailability: options.apktoolAnalysisAvailability }), closeProcessResources: async () => { try { await processResources.retryCleanup(); diff --git a/tests/boundary/mcp/jebMcp.test.ts b/tests/boundary/mcp/jebMcp.test.ts index 44b4827142..742e28929e 100644 --- a/tests/boundary/mcp/jebMcp.test.ts +++ b/tests/boundary/mcp/jebMcp.test.ts @@ -2,16 +2,102 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; -import type { JebAnalysisPort } from "../../../src/application/jeb/JebAnalysisPort.js"; +import type { + JebAnalysisPort, + JebAnalysisAvailability, +} from "../../../src/application/jeb/JebAnalysisPort.js"; import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { ok } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; const identity = { id: "jeb", name: "JEB", version: "5.48.0" } as const; +it("discovers current JEB operation support and recovery through binary_session", async () => { + let availability: JebAnalysisAvailability = { + status: "unavailable", + code: "not_configured", + reason: "Start JEB at REA_JEB_MCP_URL", + diagnostics: {}, + }; + let probes = 0; + const provider: JebAnalysisPort = { + async inspectAvailability() { + probes += 1; + return availability; + }, + async close() {}, + async execute() { + throw new Error("Status must not execute analysis"); + }, + }; + const session = createTestBinarySession(() => { + throw new Error("Status must not start a deep provider"); + }); + const server = createServer( + { kind: "session", session }, + { environment: {}, jebAnalysis: provider }, + ); + const client = new Client({ name: "jeb-availability", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const read = async () => { + const result = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + const status = toolContract("binary_session").outputSchema.parse( + result.structuredContent, + ).result; + return expandToolAvailability(status.tool_availability).filter( + (tool) => tool.surface === "jeb-provider", + ); + }; + const unavailable = await read(); + expect(unavailable).toHaveLength(4); + expect( + unavailable.every( + (tool) => !tool.available && tool.remediation === availability.reason, + ), + ).toBe(true); + availability = { + status: "available", + code: null, + reason: null, + diagnostics: {}, + supportedOperations: [ + "inspect_jeb_client", + "list_jeb_units", + "decompile_jeb_item", + ], + }; + const recovered = await read(); + expect( + recovered + .filter((tool) => tool.available) + .map((tool) => tool.name) + .sort(), + ).toEqual(["decompile_jeb_item", "inspect_jeb_client", "list_jeb_units"]); + expect( + recovered.find((tool) => tool.name === "open_jeb_project"), + ).toMatchObject({ + available: false, + reason: "provider_unavailable", + remediation: expect.stringContaining("does not advertise"), + }); + expect(probes).toBe(2); +}); + it("advertises exact valid JEB schemas and records inline evidence through MCP", async () => { const execution = createAnalysisExecution( { @@ -28,7 +114,17 @@ it("advertises exact valid JEB schemas and records inline evidence through MCP", ); const provider: JebAnalysisPort = { async inspectAvailability() { - return { status: "available", code: null, reason: null, diagnostics: {} }; + return { + status: "available", + code: null, + reason: null, + diagnostics: {}, + supportedOperations: [ + "inspect_jeb_client", + "list_jeb_units", + "decompile_jeb_item", + ], + }; }, async close() {}, async execute() { @@ -81,7 +177,17 @@ it("preserves JEB provider failure reasons through the MCP boundary", async () = const contract = toolContract("list_jeb_units"); const provider: JebAnalysisPort = { async inspectAvailability() { - return { status: "available", code: null, reason: null, diagnostics: {} }; + return { + status: "available", + code: null, + reason: null, + diagnostics: {}, + supportedOperations: [ + "inspect_jeb_client", + "list_jeb_units", + "decompile_jeb_item", + ], + }; }, async close() {}, async execute() { diff --git a/tests/boundary/providers/jebMcpCatalog.test.ts b/tests/boundary/providers/jebMcpCatalog.test.ts new file mode 100644 index 0000000000..e61f4bec13 --- /dev/null +++ b/tests/boundary/providers/jebMcpCatalog.test.ts @@ -0,0 +1,144 @@ +import { createServer } from "node:http"; + +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createStreamableHttpJebMcpConnection } from "../../../src/jeb/JebMcpConnection.js"; +import { AnalysisTimeoutError } from "../../../src/domain/analysisErrorCore.js"; + +const requestSchema = z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string(), + params: z.object({ cursor: z.string().optional() }).passthrough().optional(), +}); + +it("reads every JEB catalog page and refreshes the connected engine catalog", async () => { + let includeOpen = true; + let repeatedCursor = false; + let infinitePages = false; + let pageCount = 0; + const cursors: (string | undefined)[] = []; + const server = createServer(async (request, response) => { + if (request.method !== "POST") { + request.resume(); + response.writeHead(request.method === "DELETE" ? 200 : 405).end(); + return; + } + let body = ""; + for await (const chunk of request) body += String(chunk); + const message = requestSchema.parse(JSON.parse(body)); + if (message.id === undefined) { + if ( + message.method !== "notifications/initialized" && + message.method !== "notifications/cancelled" + ) + throw new Error(`Unexpected JEB notification: ${message.method}`); + response.writeHead(202).end(); + return; + } + let result: unknown; + if (message.method === "initialize") { + result = { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "jeb-catalog-fixture", version: "1" }, + }; + } else if (message.method === "tools/list") { + const cursor = message.params?.cursor; + pageCount += 1; + if (!infinitePages) cursors.push(cursor); + const names = + cursor === undefined + ? ["get_client_information"] + : includeOpen + ? ["open_project"] + : ["list_units"]; + result = { + tools: [...names, "unused_extension"].map((name) => ({ + name, + inputSchema: { type: "object" }, + })), + ...(infinitePages + ? { nextCursor: `fresh-page-${pageCount}` } + : cursor === undefined || repeatedCursor + ? { nextCursor: "second-page" } + : {}), + }; + } else { + throw new Error(`Unexpected JEB request: ${message.method}`); + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Expected an ephemeral TCP listener"); + const connection = createStreamableHttpJebMcpConnection( + new URL(`http://127.0.0.1:${address.port}/mcp`), + 1_000, + ); + try { + expect(await connection.listTools()).toEqual([ + "get_client_information", + "open_project", + ]); + includeOpen = false; + expect(await connection.listTools()).toEqual([ + "get_client_information", + "list_units", + ]); + expect(cursors).toEqual([ + undefined, + "second-page", + undefined, + "second-page", + ]); + infinitePages = true; + const before = pageCount; + await expect(connection.listTools()).rejects.toBeInstanceOf( + AnalysisTimeoutError, + ); + expect(pageCount - before).toBeGreaterThan(2); + infinitePages = false; + expect(await connection.listTools()).toEqual([ + "get_client_information", + "list_units", + ]); + + // Shared initial acquisition also owns a finite deadline and can recover. + const acquiring = createStreamableHttpJebMcpConnection( + new URL(`http://127.0.0.1:${address.port}/mcp`), + 1_000, + ); + try { + infinitePages = true; + await expect(acquiring.listTools()).rejects.toBeInstanceOf( + AnalysisTimeoutError, + ); + infinitePages = false; + expect(await acquiring.listTools()).toEqual([ + "get_client_information", + "list_units", + ]); + } finally { + await acquiring.close(); + } + repeatedCursor = true; + await expect(connection.listTools()).rejects.toThrow( + "repeated a tool catalog cursor", + ); + repeatedCursor = false; + expect(await connection.listTools()).toEqual([ + "get_client_information", + "list_units", + ]); + } finally { + await connection.close(); + await new Promise((resolve, reject) => + server.close((cause) => + cause === undefined ? resolve() : reject(cause), + ), + ); + } +});