fix(jeb): probe live operations with owned discovery deadlines

This commit is contained in:
morluto committed 2026-10-15 03:43:29 +08:00
1 parent 45d5e80144
commit a8887a9a85
11 files changed
+480 -102

No files matched your search

+18 -10
View File
@@ -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)
+9 -2
View File
@@ -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<ProviderAvailability, { status: "available" }> & {
readonly supportedOperations: readonly JebOperation[];
})
| Extract<ProviderAvailability, { status: "unavailable" }>;
/**
* 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<ProviderAvailability>;
inspectAvailability(signal?: AbortSignal): Promise<JebAnalysisAvailability>;
/** Release engine connection state; the JEB client itself keeps running. */
close(): Promise<void>;
execute(
@@ -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() {},
+90 -12
View File
@@ -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<Record<JebOperation, string>>;
const RELEVANT_ENGINE_TOOLS = new Set<string>(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<readonly string[]>;
/** Refresh support for REA operations from the live engine catalog. */
listTools(signal?: AbortSignal): Promise<readonly string[]>;
/** 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<readonly string[]> => {
const names = new Set<string>();
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];
};
+61 -15
View File
@@ -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<Record<string, JsonValue | Error>>,
@@ -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) {
+10 -3
View File
@@ -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<ProviderAvailability> {
): Promise<JebAnalysisAvailability> {
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,
+1
View File
@@ -581,6 +581,7 @@ export const createServer = (
adbDevice.inspectAvailability(signal),
apktoolAnalysisAvailability: (signal) =>
apktool.inspectAvailability(signal),
jebAnalysisAvailability: (signal) => jeb.inspectAvailability(signal),
startedAt,
});
}
+23 -8
View File
@@ -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<typeof toolContract<"binary_session">>;
readonly startedAt: string;
readonly availabilityPolicy: () => AvailabilityPolicy;
/** Provider-owned live probes used by session discovery. */
export interface SessionAvailabilityProbes {
readonly androidAnalysisAvailability: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
@@ -36,6 +32,18 @@ export interface SessionStatusToolOptions {
readonly apktoolAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly jebAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<JebAnalysisAvailability>;
}
/** Inputs required to register the binary session status tool. */
export interface SessionStatusToolOptions extends SessionAvailabilityProbes {
readonly server: EvidenceMcpServer;
readonly session: BinarySessionPort;
readonly contract: ReturnType<typeof toolContract<"binary_session">>;
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,
);
+9 -49
View File
@@ -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<typeof toolContract<"binary_session">>;
readonly startedAt: string;
readonly availabilityPolicy: () => AvailabilityPolicy;
readonly androidAnalysisAvailability: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly adbAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly apktoolAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly closeProcessResources: () => Promise<Result<null, AnalysisError>>;
}
@@ -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<SessionAvailabilityProbes> {
readonly artifactResources: ArtifactResourceScope;
readonly startedAt?: string;
readonly availabilityPolicy?: () => AvailabilityPolicy;
readonly androidAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly adbAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly apktoolAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
}
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();
+109 -3
View File
@@ -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() {
@@ -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<void>((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<void>((resolve, reject) =>
server.close((cause) =>
cause === undefined ? resolve() : reject(cause),
),
);
}
});