fix(jeb): probe live operations with owned discovery deadlines
This commit is contained in:
1 parent
45d5e80144
commit
a8887a9a85
11 files changed
+480
-102
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -581,6 +581,7 @@ export const createServer = (
|
||||
adbDevice.inspectAvailability(signal),
|
||||
apktoolAnalysisAvailability: (signal) =>
|
||||
apktool.inspectAvailability(signal),
|
||||
jebAnalysisAvailability: (signal) => jeb.inspectAvailability(signal),
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user