fix(mcp): isolate optional observation adapter failures (#838)

This commit is contained in:
N0zoM1z0
2026-10-07 11:45:17 +08:00
committed by GitHub
parent 78f4848d70
commit be3cfbffdf
31 changed files with 741 additions and 102 deletions
+3 -1
View File
@@ -51,7 +51,8 @@
"**/hopper/**",
"**/ida/**",
"**/android/**",
"**/firmware/**"
"**/firmware/**",
"**/browser/*Provider.*"
],
"message": "Provider imports belong behind typed ports or in production composition."
}
@@ -111,6 +112,7 @@
"**/ida/**",
"**/android/**",
"**/firmware/**",
"**/browser/*Provider.*",
"**/composition/**"
],
"message": "Application workflows use typed ports; production construction belongs in composition."
+24
View File
@@ -93,3 +93,27 @@ providers. Composition may import their implementations. Binary composition is
still at its existing entrypoint while its direct-analysis callers are investigated.
This concrete pilot does not introduce a shared provider interface or module
manifest.
## Independent optional observation adapters
Browser observation, browser scenarios, Electron observation, Electron scenarios
and V8 Inspector observation now have separate factories in `src/composition/`.
CLI commands reuse them; MCP startup dynamically imports each factory independently.
`OptionalObservationProviders` describes these five existing typed ports only. It
is provisional startup wiring, not an extensible plugin manifest or a universal
provider interface.
A failed import or constructor retains the successful peers. The complete tool
catalog remains advertised. Session availability reports the failed adapter and
its actual reason, and affected handlers return the existing capability-unavailable
error. Explicit loading failures take precedence over configured availability.
Passive Electron and active Electron failures are independent; static JavaScript
analysis and runtime reconciliation remain usable without either adapter. CLI
Electron commands also load only their selected runtime factory at execution.
Source identities are available without importing the optional implementations.
Registration acquires no engines, probes no endpoints, changes no permissions and
installs nothing. Core transport failures and shutdown still use their existing
lifecycle. Fault-injection coverage uses the production loading/startup path and
actual MCP SDK calls; recording ports establish composition behavior, while the
real Inspector lane separately establishes runtime behavior.
+28
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
import type {
OptionalProviderLoadFailures,
OptionalProviderLoadFailure,
} from "./OptionalObservationProviders.js";
import {
providerCapability,
@@ -28,6 +32,7 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
type ProviderDescriptor = ProviderCapability;
export type AvailabilityPolicy = {
readonly processCaptureEnabled: boolean;
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
readonly firmwareInspectionEnabled?: boolean;
readonly firmwareExtractionEnabled?: boolean;
readonly androidAnalysisEnabled?: boolean;
@@ -186,6 +191,12 @@ const availabilityFor = (context: AvailabilityContext): Availability => {
return navigationContextAvailability(context.descriptors);
const javascriptApplication = javascriptApplicationAvailability(context);
if (javascriptApplication !== null) return javascriptApplication;
const failure = optionalProviderFailureFor(context);
if (failure !== undefined)
return {
reason: "provider_unavailable",
remediation: `Adapter ${failure.providerId} could not load: ${failure.reason}. Repair the adapter installation and restart REA; other capabilities remain available.`,
};
const workflowAvailability = workflowAvailabilityFor(context);
if (workflowAvailability !== null) return workflowAvailability;
const targetDecision = targetAvailability(context);
@@ -193,6 +204,23 @@ const availabilityFor = (context: AvailabilityContext): Availability => {
return providerAvailability(context);
};
const optionalProviderFailureFor = ({
name,
kind,
policy,
}: AvailabilityContext): OptionalProviderLoadFailure | undefined => {
const failures = policy.optionalProviderLoadFailures;
if (name === "capture_browser_scenario")
return failures?.browserScenarioCapture;
if (kind === "browser-provider") return failures?.browserObservation;
if (name === "capture_electron_scenario")
return failures?.electronActiveObservation;
if (kind === "electron-provider") return failures?.electronObservation;
if (kind === "runtime-provider")
return failures?.javascriptRuntimeObservation;
return undefined;
};
const javascriptApplicationAvailability = ({
name,
}: AvailabilityContext): Availability | null => {
@@ -0,0 +1,43 @@
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
import type { ElectronObservationPort } from "./ElectronObservationPort.js";
import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js";
import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
/** Existing observation ports loaded independently at MCP startup. */
export interface OptionalObservationProviders {
readonly browserObservation?: BrowserObservationPort;
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
readonly electronObservation?: ElectronObservationPort;
readonly electronActiveObservation?: ElectronActiveObservationPort;
readonly javascriptRuntimeObservation?: JavaScriptRuntimeObservationPort;
}
/** A module failed before its port could be constructed; no engine was opened. */
export interface OptionalProviderLoadFailure {
readonly providerId: string;
readonly reason: string;
}
export type OptionalProviderLoadFailures = Readonly<
Partial<
Record<keyof OptionalObservationProviders, OptionalProviderLoadFailure>
>
>;
/** Successful ports and failed peers from one independent loading attempt. */
export interface OptionalProviderLoadResult extends OptionalObservationProviders {
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
}
/** Preserve the module's failure reason through the existing tool error algebra. */
export const optionalProviderUnavailable = (
failure: OptionalProviderLoadFailure,
operation: string,
): AnalysisCapabilityUnavailableError =>
new AnalysisCapabilityUnavailableError(
failure.providerId,
operation,
`Adapter could not load: ${failure.reason}`,
);
+2 -5
View File
@@ -64,11 +64,8 @@ import { captureCdpScreenshot } from "./CdpScreenshot.js";
import { comparePngScreenshots } from "./PngVisualDiff.js";
/** Public identity committed by passive browser observations. */
export const CDP_BROWSER_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
id: "rea-cdp-browser",
name: "REA Chrome DevTools Protocol observation provider",
version: "2",
});
export { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
const IDENTITY = CDP_BROWSER_PROVIDER_IDENTITY;
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
+2 -5
View File
@@ -30,11 +30,8 @@ import { inspectCdpElectronPage } from "./CdpElectronInspection.js";
import { authorizedElectronFile } from "./ElectronFileScope.js";
/** Public identity committed by passive Electron observations. */
export const CDP_ELECTRON_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
id: "rea-cdp-electron",
name: "REA Electron file-page CDP observation provider",
version: "1",
});
export { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js";
const IDENTITY = CDP_ELECTRON_PROVIDER_IDENTITY;
/** Passive Electron provider for local file pages exposed by loopback CDP. */
@@ -41,12 +41,8 @@ const lazyPlaywrightFactory: BrowserScenarioSessionFactory = {
},
};
export const PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY: ProviderIdentity =
Object.freeze({
id: "rea-playwright-browser-scenario",
name: "REA Playwright browser scenario capture provider",
version: "1",
});
export { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "./providerIdentities.js";
type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number];
const SNAPSHOT_KINDS = [
@@ -42,12 +42,8 @@ const OPERATION = "capture_electron_scenario" as const;
const STARTUP_TIMEOUT_MS = 60_000;
/** Public identity for provider-owned Electron runtime experiments. */
export const PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY: ProviderIdentity =
Object.freeze({
id: "rea-playwright-electron-active",
name: "REA Playwright active Electron observation provider",
version: "1",
});
export { PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY } from "./providerIdentities.js";
const hookPath = fileURLToPath(
new URL("../../scripts/electron-active-hook.cjs", import.meta.url),
+2 -5
View File
@@ -39,11 +39,8 @@ import {
} from "./V8InspectorEndpoint.js";
/** Public identity committed by passive V8 Inspector observations. */
export const V8_INSPECTOR_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
id: "rea-v8-inspector",
name: "REA passive Node/Electron V8 Inspector provider",
version: "1",
});
export { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentities.js";
export interface ScriptDraft {
readonly rawUrl: string;
+38
View File
@@ -0,0 +1,38 @@
import type { ProviderIdentity } from "../application/AnalysisProvider.js";
/** Identity available without loading the CdpBrowserProvider implementation. */
export const CDP_BROWSER_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
id: "rea-cdp-browser",
name: "REA Chrome DevTools Protocol observation provider",
version: "2",
});
/** Identity available without loading the PlaywrightBrowserScenarioProvider implementation. */
export const PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY: ProviderIdentity =
Object.freeze({
id: "rea-playwright-browser-scenario",
name: "REA Playwright browser scenario capture provider",
version: "1",
});
/** Identity available without loading the CdpElectronProvider implementation. */
export const CDP_ELECTRON_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
id: "rea-cdp-electron",
name: "REA Electron file-page CDP observation provider",
version: "1",
});
/** Identity available without loading the PlaywrightElectronActiveProvider implementation. */
export const PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY: ProviderIdentity =
Object.freeze({
id: "rea-playwright-electron-active",
name: "REA Playwright active Electron observation provider",
version: "1",
});
/** Identity available without loading the V8InspectorProvider implementation. */
export const V8_INSPECTOR_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
id: "rea-v8-inspector",
name: "REA passive Node/Electron V8 Inspector provider",
version: "1",
});
+7 -12
View File
@@ -6,8 +6,6 @@ import {
} from "../application/ElectronObservationService.js";
import { captureElectronScenario } from "../application/ElectronActiveObservationService.js";
import { reconcileJavaScriptRuntimeEvidence } from "../application/JavaScriptRuntimeReconciliationService.js";
import { CdpElectronProvider } from "../browser/CdpElectronProvider.js";
import { PlaywrightElectronActiveProvider } from "../browser/PlaywrightElectronActiveProvider.js";
import { logCliCommand } from "../cliLogging.js";
import {
inspectElectronPageInputSchema,
@@ -61,9 +59,10 @@ const registerElectronActiveCommand = (
input.value,
);
if (!parsed.success) return inputError("capture_electron_scenario");
const context = await electronContext();
const { createElectronScenarioProvider } =
await import("../composition/electronScenario.js");
const result = await captureElectronScenario(
context.activeProvider,
createElectronScenarioProvider(),
parsed.data,
);
return result.ok ? result.value : cliError(result.error);
@@ -181,16 +180,12 @@ const registerJavaScriptApplicationCommand = (
});
};
const electronContext = async () => {
return {
ok: true as const,
provider: new CdpElectronProvider(),
activeProvider: new PlaywrightElectronActiveProvider(),
};
const electronObservationContext = async () => {
const { createElectronObservationProvider } =
await import("../composition/electronObservation.js");
return { provider: createElectronObservationProvider() };
};
const electronObservationContext = electronContext;
const inputError = (operation: string): JsonValue =>
cliError(new AnalysisInputError(operation));
+4 -2
View File
@@ -1,9 +1,11 @@
import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js";
import { createBrowserObservationProvider } from "./composition/browserObservation.js";
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
import type { JsonValue } from "./domain/jsonValue.js";
/** Build the passive browser provider; scope is supplied on each request. */
export const browserContext = () => ({ provider: new CdpBrowserProvider() });
export const browserContext = () => ({
provider: createBrowserObservationProvider(),
});
/** Project a browser operation failure consistently across CLI command groups. */
export const browserCliError = (
+2 -2
View File
@@ -2,7 +2,7 @@ import { Cli } from "incur";
import { z } from "zod";
import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js";
import { PlaywrightBrowserScenarioProvider } from "./browser/PlaywrightBrowserScenarioProvider.js";
import { createBrowserScenarioProvider } from "./composition/browserScenario.js";
import { CLI_COMMANDS } from "./cliCommandNames.js";
import { parseCliJsonInput } from "./cliJsonInput.js";
import { logCliCommand } from "./cliLogging.js";
@@ -35,7 +35,7 @@ export const registerBrowserScenarioCommands = (
if (!scenario.success)
return cliError(new AnalysisInputError(OPERATION));
const result = await captureBrowserScenario(
new PlaywrightBrowserScenarioProvider(),
createBrowserScenarioProvider(),
scenario.data,
);
return result.ok ? result.value : cliError(result.error);
+4 -2
View File
@@ -4,7 +4,7 @@ import {
listJavaScriptRuntimeTargets,
observeJavaScriptRuntime,
} from "./application/JavaScriptRuntimeObservationService.js";
import { V8InspectorProvider } from "./browser/V8InspectorProvider.js";
import { createJavaScriptRuntimeObservationProvider } from "./composition/javascriptRuntimeObservation.js";
import { CLI_COMMANDS } from "./cliCommandNames.js";
import { logCliCommand } from "./cliLogging.js";
import {
@@ -89,7 +89,9 @@ export const registerJavaScriptRuntimeObservationCommands = (
});
};
const runtimeContext = () => ({ provider: new V8InspectorProvider() });
const runtimeContext = () => ({
provider: createJavaScriptRuntimeObservationProvider(),
});
const inputError = (operation: string): JsonValue =>
cliError(new AnalysisInputError(operation));
+6
View File
@@ -0,0 +1,6 @@
import type { BrowserObservationPort } from "../application/BrowserObservationPort.js";
import { CdpBrowserProvider } from "../browser/CdpBrowserProvider.js";
/** Construct a fresh provider without opening a target or acquiring an engine. */
export const createBrowserObservationProvider = (): BrowserObservationPort =>
new CdpBrowserProvider();
+6
View File
@@ -0,0 +1,6 @@
import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js";
import { PlaywrightBrowserScenarioProvider } from "../browser/PlaywrightBrowserScenarioProvider.js";
/** Construct a fresh provider without opening a target or acquiring an engine. */
export const createBrowserScenarioProvider = (): BrowserScenarioCapturePort =>
new PlaywrightBrowserScenarioProvider();
+6
View File
@@ -0,0 +1,6 @@
import type { ElectronObservationPort } from "../application/ElectronObservationPort.js";
import { CdpElectronProvider } from "../browser/CdpElectronProvider.js";
/** Construct a fresh provider without opening a target or acquiring an engine. */
export const createElectronObservationProvider = (): ElectronObservationPort =>
new CdpElectronProvider();
+6
View File
@@ -0,0 +1,6 @@
import type { ElectronActiveObservationPort } from "../application/ElectronActiveObservationPort.js";
import { PlaywrightElectronActiveProvider } from "../browser/PlaywrightElectronActiveProvider.js";
/** Construct a fresh provider without opening a target or acquiring an engine. */
export const createElectronScenarioProvider =
(): ElectronActiveObservationPort => new PlaywrightElectronActiveProvider();
@@ -0,0 +1,6 @@
import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js";
import { V8InspectorProvider } from "../browser/V8InspectorProvider.js";
/** Construct a fresh provider without opening a target or acquiring an engine. */
export const createJavaScriptRuntimeObservationProvider =
(): JavaScriptRuntimeObservationPort => new V8InspectorProvider();
@@ -0,0 +1,97 @@
import type {
OptionalObservationProviders,
OptionalProviderLoadFailure,
OptionalProviderLoadResult,
} from "../application/OptionalObservationProviders.js";
import {
CDP_BROWSER_PROVIDER_IDENTITY,
PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY,
CDP_ELECTRON_PROVIDER_IDENTITY,
PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY,
V8_INSPECTOR_PROVIDER_IDENTITY,
} from "../browser/providerIdentities.js";
import { err, ok, type Result } from "../domain/result.js";
/** Precisely typed startup factories for the five existing observation ports. */
export type OptionalObservationFactories = {
readonly [Port in keyof OptionalObservationProviders]-?: () => Promise<
NonNullable<OptionalObservationProviders[Port]>
>;
};
const productionFactories: OptionalObservationFactories = {
browserObservation: async () =>
(
await import("./browserObservation.js")
).createBrowserObservationProvider(),
browserScenarioCapture: async () =>
(await import("./browserScenario.js")).createBrowserScenarioProvider(),
electronObservation: async () =>
(
await import("./electronObservation.js")
).createElectronObservationProvider(),
electronActiveObservation: async () =>
(await import("./electronScenario.js")).createElectronScenarioProvider(),
javascriptRuntimeObservation: async () =>
(
await import("./javascriptRuntimeObservation.js")
).createJavaScriptRuntimeObservationProvider(),
};
const loadPort = async <Port>(
factory: () => Promise<Port>,
providerId: string,
): Promise<Result<Port, OptionalProviderLoadFailure>> => {
try {
return ok(await factory());
} catch (cause: unknown) {
return err({
providerId,
reason: cause instanceof Error ? cause.message : String(cause),
});
}
};
/** Load each adapter independently, retaining healthy peers and exact failures. */
export const loadOptionalObservationProviders = async (
factories: OptionalObservationFactories = productionFactories,
): Promise<OptionalProviderLoadResult> => {
const [browser, scenario, electron, electronActive, runtime] =
await Promise.all([
loadPort(factories.browserObservation, CDP_BROWSER_PROVIDER_IDENTITY.id),
loadPort(
factories.browserScenarioCapture,
PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY.id,
),
loadPort(
factories.electronObservation,
CDP_ELECTRON_PROVIDER_IDENTITY.id,
),
loadPort(
factories.electronActiveObservation,
PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY.id,
),
loadPort(
factories.javascriptRuntimeObservation,
V8_INSPECTOR_PROVIDER_IDENTITY.id,
),
]);
return {
...(browser.ok ? { browserObservation: browser.value } : {}),
...(scenario.ok ? { browserScenarioCapture: scenario.value } : {}),
...(electron.ok ? { electronObservation: electron.value } : {}),
...(electronActive.ok
? { electronActiveObservation: electronActive.value }
: {}),
...(runtime.ok ? { javascriptRuntimeObservation: runtime.value } : {}),
optionalProviderLoadFailures: {
...(browser.ok ? {} : { browserObservation: browser.error }),
...(scenario.ok ? {} : { browserScenarioCapture: scenario.error }),
...(electron.ok ? {} : { electronObservation: electron.error }),
...(electronActive.ok
? {}
: { electronActiveObservation: electronActive.error }),
...(runtime.ok ? {} : { javascriptRuntimeObservation: runtime.error }),
},
};
};
+2 -23
View File
@@ -14,6 +14,7 @@ import { SERVER_START_FAILED } from "./main/messages.js";
import { createRuntimeState } from "./main/state.js";
import { openInitialTarget } from "./main/startup.js";
import { startMcpTransport } from "./main/transport.js";
import { loadOptionalObservationProviders } from "./composition/optionalObservationProviders.js";
import { registerConfigReload } from "./main/reload.js";
import { createShutdown } from "./main/shutdown.js";
@@ -70,29 +71,7 @@ export const run = async (
logger,
serverLogger,
loadOptionalProviders:
dependencies.loadOptionalProviders ??
(async () => {
const [
{ CdpBrowserProvider },
{ PlaywrightBrowserScenarioProvider },
{ CdpElectronProvider },
{ PlaywrightElectronActiveProvider },
{ V8InspectorProvider },
] = await Promise.all([
import("./browser/CdpBrowserProvider.js"),
import("./browser/PlaywrightBrowserScenarioProvider.js"),
import("./browser/CdpElectronProvider.js"),
import("./browser/PlaywrightElectronActiveProvider.js"),
import("./browser/V8InspectorProvider.js"),
]);
return {
browserObservation: new CdpBrowserProvider(),
browserScenarioCapture: new PlaywrightBrowserScenarioProvider(),
electronObservation: new CdpElectronProvider(),
electronActiveObservation: new PlaywrightElectronActiveProvider(),
javascriptRuntimeObservation: new V8InspectorProvider(),
};
}),
dependencies.loadOptionalProviders ?? loadOptionalObservationProviders,
});
if (!transport.ok) return 1;
const unregisterReload = registerConfigReload({
+11 -12
View File
@@ -2,25 +2,16 @@ import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio";
import type { BinarySession } from "../application/BinarySession.js";
import type { Logger } from "../logger.js";
import {
createServer,
type CreateServerOptions,
} from "../server/createServer.js";
import { createServer } from "../server/createServer.js";
import type { RuntimeDependencies } from "./types.js";
import type { OptionalProviderLoadResult } from "../application/OptionalObservationProviders.js";
import {
MCP_CONNECTION_LOST,
MCP_CONNECTION_START_FAILED,
} from "./messages.js";
/** Optional adapters whose absence must not prevent the core MCP server. */
export type OptionalProviders = Pick<
CreateServerOptions,
| "browserObservation"
| "browserScenarioCapture"
| "electronObservation"
| "electronActiveObservation"
| "javascriptRuntimeObservation"
>;
export type OptionalProviders = OptionalProviderLoadResult;
interface ServerContext {
readonly logger: Logger;
@@ -51,6 +42,14 @@ export const startMcpTransport = async (
"Optional MCP providers could not load; affected tools remain unavailable",
);
}
for (const failure of Object.values(
optionalProviders.optionalProviderLoadFailures ?? {},
)) {
serverLogger.warn(
{ providerId: failure.providerId, error: failure.reason },
"Optional MCP adapter could not load; its peers remain available",
);
}
let handle: StdioServerHandle;
try {
handle = dependencies.serve(
+11
View File
@@ -8,6 +8,7 @@ import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioC
import type { ElectronActiveObservationPort } from "../application/ElectronActiveObservationPort.js";
import type { ElectronObservationPort } from "../application/ElectronObservationPort.js";
import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js";
import type { OptionalProviderLoadFailures } from "../application/OptionalObservationProviders.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { silentLogger, type Logger } from "../logger.js";
import { registerApplicationTools } from "./registerApplicationTools.js";
@@ -51,6 +52,7 @@ export interface CreateServerOptions {
readonly electronActiveObservation?: ElectronActiveObservationPort;
readonly javascriptRuntimeObservation?: JavaScriptRuntimeObservationPort;
readonly availabilityPolicy?: () => SessionAvailability;
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
}
const installSessionToolAvailability = (
@@ -60,6 +62,7 @@ const installSessionToolAvailability = (
) => {
if (session === undefined) return undefined;
const policy = sessionAvailabilityPolicy(options.availabilityPolicy, {
optionalProviderLoadFailures: options.optionalProviderLoadFailures,
optionalFeatures: {
firmwareInspectionEnabled:
options.firmwareAnalysis !== undefined ||
@@ -259,19 +262,27 @@ const registerObservationTools = ({
registerBrowserTools(server, {
...common,
browser: options.browserObservation,
loadFailure: options.optionalProviderLoadFailures?.browserObservation,
});
registerBrowserScenarioTool(server, {
...common,
provider: options.browserScenarioCapture,
loadFailure: options.optionalProviderLoadFailures?.browserScenarioCapture,
});
registerElectronTools(server, {
...common,
electron: options.electronObservation,
electronActive: options.electronActiveObservation,
observationLoadFailure:
options.optionalProviderLoadFailures?.electronObservation,
activeLoadFailure:
options.optionalProviderLoadFailures?.electronActiveObservation,
});
registerJavaScriptRuntimeObservationTools(server, {
...common,
runtime: options.javascriptRuntimeObservation,
loadFailure:
options.optionalProviderLoadFailures?.javascriptRuntimeObservation,
});
registerApplicationTools(server, {
evidenceById,
+11
View File
@@ -1,3 +1,8 @@
import {
optionalProviderUnavailable,
type OptionalProviderLoadFailure,
} from "../application/OptionalObservationProviders.js";
import { err } from "../domain/result.js";
import type { McpServer } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/BinarySession.js";
@@ -12,6 +17,7 @@ import { toCallToolResult } from "./toolResult.js";
interface BrowserScenarioToolRegistration {
readonly logger: Logger;
readonly loadFailure?: OptionalProviderLoadFailure | undefined;
readonly provider: BrowserScenarioCapturePort | undefined;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
}
@@ -27,6 +33,11 @@ export const registerBrowserScenarioTool = (
toolRegistrationOptions(contract),
async (input, context) => {
const scenario = browserScenarioSchema.parse(input);
if (options.loadFailure !== undefined)
return toCallToolResult(
err(optionalProviderUnavailable(options.loadFailure, contract.name)),
contract,
);
const result = await logToolExecution(options.logger, contract.name, () =>
captureBrowserScenario(options.provider, scenario, {
signal: context.mcpReq.signal,
+11
View File
@@ -1,3 +1,8 @@
import {
optionalProviderUnavailable,
type OptionalProviderLoadFailure,
} from "../application/OptionalObservationProviders.js";
import { err } from "../domain/result.js";
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/BinarySession.js";
@@ -28,6 +33,7 @@ import { toCallToolResult } from "./toolResult.js";
interface BrowserToolRegistration {
readonly logger: Logger;
readonly loadFailure?: OptionalProviderLoadFailure | undefined;
readonly browser: BrowserObservationPort | undefined;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
}
@@ -182,6 +188,11 @@ const runBrowserTool = async <Input>(
) => Promise<Result<Evidence, AnalysisError>>,
) => {
const { input, context } = request;
if (options.loadFailure !== undefined)
return toCallToolResult(
err(optionalProviderUnavailable(options.loadFailure, contract.name)),
contract,
);
const result = await logToolExecution(options.logger, contract.name, () =>
execute(input, {
signal: context.mcpReq.signal,
+19
View File
@@ -1,3 +1,8 @@
import {
optionalProviderUnavailable,
type OptionalProviderLoadFailure,
} from "../application/OptionalObservationProviders.js";
import { err } from "../domain/result.js";
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/BinarySession.js";
@@ -25,6 +30,8 @@ import { toCallToolResult } from "./toolResult.js";
interface ElectronToolRegistration {
readonly logger: Logger;
readonly observationLoadFailure?: OptionalProviderLoadFailure | undefined;
readonly activeLoadFailure?: OptionalProviderLoadFailure | undefined;
readonly electron: ElectronObservationPort | undefined;
readonly electronActive: ElectronActiveObservationPort | undefined;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
@@ -134,6 +141,18 @@ const runElectronTool = async <Input>(
) => Promise<Result<Evidence, AnalysisError>>,
) => {
const { input, context } = request;
const failure =
contract.name === "capture_electron_scenario"
? options.activeLoadFailure
: contract.name === "list_electron_targets" ||
contract.name === "inspect_electron_page"
? options.observationLoadFailure
: undefined;
if (failure !== undefined)
return toCallToolResult(
err(optionalProviderUnavailable(failure, contract.name)),
contract,
);
const result = await logToolExecution(options.logger, contract.name, () =>
execute(input, {
signal: context.mcpReq.signal,
@@ -1,3 +1,8 @@
import {
optionalProviderUnavailable,
type OptionalProviderLoadFailure,
} from "../application/OptionalObservationProviders.js";
import { err } from "../domain/result.js";
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/BinarySession.js";
@@ -19,6 +24,7 @@ import { toCallToolResult } from "./toolResult.js";
interface RuntimeToolRegistration {
readonly logger: Logger;
readonly loadFailure?: OptionalProviderLoadFailure | undefined;
readonly runtime: JavaScriptRuntimeObservationPort | undefined;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
}
@@ -68,6 +74,11 @@ const runRuntimeTool = async <Input>(
) => Promise<Result<Evidence, AnalysisError>>,
) => {
const { input, context } = request;
if (options.loadFailure !== undefined)
return toCallToolResult(
err(optionalProviderUnavailable(options.loadFailure, contract.name)),
contract,
);
const result = await logToolExecution(options.logger, contract.name, () =>
execute(input, context.mcpReq.signal),
);
+35 -21
View File
@@ -1,9 +1,13 @@
import type { AvailabilityPolicy } from "../application/CapabilityInventory.js";
import type { OptionalProviderLoadFailures } from "../application/OptionalObservationProviders.js";
import { platform } from "node:process";
export type SessionAvailability = AvailabilityPolicy;
export interface SessionAvailabilityDefaults {
readonly optionalProviderLoadFailures?:
| OptionalProviderLoadFailures
| undefined;
readonly optionalFeatures?: Pick<
SessionAvailability,
| "browserObservationEnabled"
@@ -21,24 +25,34 @@ export interface SessionAvailabilityDefaults {
export const sessionAvailabilityPolicy = (
configured: (() => SessionAvailability) | undefined,
defaults: SessionAvailabilityDefaults,
): (() => SessionAvailability) =>
configured ??
(() => ({
processCaptureEnabled: platform !== "win32",
firmwareInspectionEnabled:
defaults.optionalFeatures?.firmwareInspectionEnabled ?? false,
firmwareExtractionEnabled:
defaults.optionalFeatures?.firmwareExtractionEnabled ?? false,
androidAnalysisEnabled:
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
browserObservationEnabled:
defaults.optionalFeatures?.browserObservationEnabled ?? false,
browserScenarioEnabled:
defaults.optionalFeatures?.browserScenarioEnabled ?? false,
electronObservationEnabled:
defaults.optionalFeatures?.electronObservationEnabled ?? false,
electronAutomationEnabled:
defaults.optionalFeatures?.electronAutomationEnabled ?? false,
v8InspectorObservationEnabled:
defaults.optionalFeatures?.v8InspectorObservationEnabled ?? false,
}));
): (() => SessionAvailability) => {
const policy =
configured ??
(() => ({
processCaptureEnabled: platform !== "win32",
firmwareInspectionEnabled:
defaults.optionalFeatures?.firmwareInspectionEnabled ?? false,
firmwareExtractionEnabled:
defaults.optionalFeatures?.firmwareExtractionEnabled ?? false,
androidAnalysisEnabled:
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
browserObservationEnabled:
defaults.optionalFeatures?.browserObservationEnabled ?? false,
browserScenarioEnabled:
defaults.optionalFeatures?.browserScenarioEnabled ?? false,
electronObservationEnabled:
defaults.optionalFeatures?.electronObservationEnabled ?? false,
electronAutomationEnabled:
defaults.optionalFeatures?.electronAutomationEnabled ?? false,
v8InspectorObservationEnabled:
defaults.optionalFeatures?.v8InspectorObservationEnabled ?? false,
}));
return () => ({
...policy(),
...(defaults.optionalProviderLoadFailures === undefined
? {}
: {
optionalProviderLoadFailures: defaults.optionalProviderLoadFailures,
}),
});
};
@@ -0,0 +1,261 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { McpServer } from "@modelcontextprotocol/server";
import { afterEach, expect } from "vitest";
import { z } from "zod";
import type { BinarySessionPort } from "../../../src/application/BinarySessionPort.js";
import {
loadOptionalObservationProviders,
type OptionalObservationFactories,
} from "../../../src/composition/optionalObservationProviders.js";
import {
TOOL_CONTRACTS,
toolContract,
} from "../../../src/contracts/toolContracts.js";
import { run } from "../../../src/main.js";
import { createServer } from "../../../src/server/createServer.js";
import { optionalObservationFactories } from "../../fixtures/optionalObservationProviders.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
const failedAdapters = [
["browserObservation", "list_browser_targets", "rea-cdp-browser"],
[
"browserScenarioCapture",
"capture_browser_scenario",
"rea-playwright-browser-scenario",
],
["electronObservation", "list_electron_targets", "rea-cdp-electron"],
[
"electronActiveObservation",
"capture_electron_scenario",
"rea-playwright-electron-active",
],
[
"javascriptRuntimeObservation",
"list_javascript_runtime_targets",
"rea-v8-inspector",
],
] as const;
const resources: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const close of resources.splice(0).reverse()) await close();
});
const boot = async (factories: OptionalObservationFactories) => {
const loaded = await loadOptionalObservationProviders(factories);
let server: McpServer | undefined;
let session: BinarySessionPort | undefined;
let shutdown: (() => void) | undefined;
let resolveClose: (() => void) | undefined;
const closed = new Promise<void>((resolve) => {
resolveClose = resolve;
});
const output: string[] = [];
const exitCodes: number[] = [];
expect(
await run({
env: {},
loadOptionalProviders: async () => loaded,
createServer: (analysis, selectedSession, options) => {
session = selectedSession;
// A configured policy cannot hide an actual adapter loading failure.
return createServer(analysis, selectedSession, {
...options,
availabilityPolicy: () => ({
processCaptureEnabled: true,
browserObservationEnabled: true,
browserScenarioEnabled: true,
electronObservationEnabled: true,
electronAutomationEnabled: true,
v8InspectorObservationEnabled: true,
}),
});
},
serve: (factory) => {
const created = factory({ era: "legacy" });
if (!(created instanceof McpServer))
throw new Error("Expected the production synchronous MCP factory");
server = created;
return {
close: async () => {
await server?.close();
resolveClose?.();
},
};
},
registerShutdown: (handler) => {
shutdown = handler;
return () => {};
},
writeStderr: (message) => output.push(message),
setExitCode: (code) => exitCodes.push(code),
}),
).toBe(0);
expect(output).toEqual([]);
if (server === undefined || session === undefined || shutdown === undefined)
throw new Error("Runtime did not construct the MCP server and session");
const close = shutdown;
resources.push(async () => {
close();
await closed;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(exitCodes).toEqual([]);
});
return { server, session, loaded };
};
mcpTest.for(failedAdapters)(
"isolates %s and retains successful peers",
async ([port, operation, providerId], { mcp }) => {
const calls: string[] = [];
const failure = `${port} synthetic import failure`;
const factories = optionalObservationFactories(calls);
const connected = await boot({
...factories,
[port]: async () => {
throw new Error(failure);
},
});
expect(connected.loaded.optionalProviderLoadFailures).toEqual({
[port]: { providerId, reason: failure },
});
for (const [peer] of failedAdapters)
expect(connected.loaded[peer] !== undefined).toBe(peer !== port);
const client = await mcp.connect(connected.server);
const names = (await client.listTools()).tools.map(({ name }) => name);
expect(new Set(names)).toEqual(
new Set(TOOL_CONTRACTS.map(({ name }) => name)),
);
expect(names).toHaveLength(TOOL_CONTRACTS.length);
const example = toolContract(operation).examples[0];
if (example === undefined)
throw new Error(`Missing example for ${operation}`);
const unavailable = await client.callTool({
name: operation,
arguments: example.input,
});
expect(unavailable.isError).toBe(true);
expect(unavailable.structuredContent).toMatchObject({
error: {
details: {
provider_id: providerId,
operation,
reason: `Adapter could not load: ${failure}`,
},
},
});
const status = await client.callTool({
name: "binary_session",
arguments: {},
});
expect(status.structuredContent).toMatchObject({
result: {
tool_availability: expect.arrayContaining([
expect.objectContaining({
name: operation,
available: false,
reason: "provider_unavailable",
remediation: expect.stringContaining(failure),
}),
expect.objectContaining({
name: "analyze_javascript_application",
available: true,
}),
expect.objectContaining({
name: "reconcile_javascript_runtime",
available: true,
}),
expect.objectContaining({
name:
port === "electronActiveObservation"
? "list_electron_targets"
: "capture_electron_scenario",
available: true,
}),
]),
},
});
const healthy =
port === "javascriptRuntimeObservation"
? "list_browser_targets"
: "list_javascript_runtime_targets";
const listed = await client.callTool({
name: healthy,
arguments:
healthy === "list_browser_targets"
? { cdp_endpoint: "http://127.0.0.1:9222" }
: { inspector_endpoint: "http://127.0.0.1:9222" },
});
expect(listed.isError).not.toBe(true);
const evidenceId = z
.object({ evidence: z.object({ evidence_id: z.string() }) })
.parse(listed.structuredContent).evidence.evidence_id;
expect(connected.session.evidenceById(evidenceId)?.operation).toBe(healthy);
expect(calls).toEqual([
port === "javascriptRuntimeObservation" ? "browser" : "runtime",
]);
const reconciled = await client.callTool({
name: "reconcile_javascript_runtime",
arguments: toolContract("reconcile_javascript_runtime").examples[0]
?.input,
});
expect(reconciled.isError).not.toBe(true);
if (
port === "electronObservation" ||
port === "electronActiveObservation"
) {
const directory = await createTestTempDirectory("rea-optional-static-");
await writeFile(
join(directory, "package.json"),
JSON.stringify({ name: "optional-isolation", main: "index.js" }),
);
await writeFile(join(directory, "index.js"), "exports.answer = 42;\n");
const analyzed = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: directory, format: "directory" },
});
expect(analyzed.isError).not.toBe(true);
expect(analyzed.structuredContent).toMatchObject({
evidence: { operation: "analyze_javascript_application" },
});
}
const malformed = await client.callTool({
name: operation,
arguments: { unexpected: true },
});
expect(malformed.isError).toBe(true);
expect(JSON.stringify(malformed)).not.toContain(failure);
},
);
mcpTest(
"starts with every optional adapter failed and creates fresh connection state",
async ({ mcp }) => {
const failed = async (): Promise<never> => {
throw new Error("all optional adapters unavailable");
};
const first = await boot({
browserObservation: failed,
browserScenarioCapture: failed,
electronObservation: failed,
electronActiveObservation: failed,
javascriptRuntimeObservation: failed,
});
const client = await mcp.connect(first.server);
expect((await client.listTools()).tools).toHaveLength(
TOOL_CONTRACTS.length,
);
const status = await client.callTool({
name: "binary_session",
arguments: {},
});
expect(status.isError).not.toBe(true);
const second = await boot(optionalObservationFactories([]));
expect(second.loaded.optionalProviderLoadFailures).toEqual({});
expect(second.session.exportEvidenceBundle().records).toEqual([]);
expect(second.loaded.browserObservation).toBeDefined();
},
);
@@ -16,6 +16,9 @@ describe("incremental module import boundaries", () => {
["src/application/probe.ts", "../android/JadxProvider.js", false],
["src/application/probe.ts", "../composition/android.js", false],
["src/server/probe.ts", "../firmware/FirmwareProvider.js", false],
["src/server/probe.ts", "../browser/CdpBrowserProvider.js", false],
["src/application/probe.ts", "../browser/V8InspectorProvider.js", false],
["src/composition/probe.ts", "../browser/CdpBrowserProvider.js", true],
["src/domain/probe.ts", "./result.js", true],
["src/contracts/probe.ts", "../domain/result.js", true],
["src/application/probe.ts", "../domain/result.js", true],
+76
View File
@@ -0,0 +1,76 @@
import type { BrowserObservationPort } from "../../src/application/BrowserObservationPort.js";
import type { JavaScriptRuntimeObservationPort } from "../../src/application/JavaScriptRuntimeObservationPort.js";
import {
CDP_BROWSER_PROVIDER_IDENTITY,
V8_INSPECTOR_PROVIDER_IDENTITY,
} from "../../src/browser/providerIdentities.js";
import { createBrowserScenarioProvider } from "../../src/composition/browserScenario.js";
import { createElectronObservationProvider } from "../../src/composition/electronObservation.js";
import { createElectronScenarioProvider } from "../../src/composition/electronScenario.js";
import type { OptionalObservationFactories } from "../../src/composition/optionalObservationProviders.js";
import { AnalysisCapabilityUnavailableError } from "../../src/domain/analysisErrorCore.js";
import { err, ok } from "../../src/domain/result.js";
const unselected = async () =>
err(
new AnalysisCapabilityUnavailableError(
"recording-fixture",
"unselected",
"This fixture implements target listing only",
),
);
/** Recording ports exercise application composition without claiming real engines. */
export const optionalObservationFactories = (
calls: string[],
): OptionalObservationFactories => {
const browser: BrowserObservationPort = {
identity: () => CDP_BROWSER_PROVIDER_IDENTITY,
listTargets: async () => {
calls.push("browser");
return ok({
browser: {
product: "Recording browser",
protocol_version: "1.3",
revision: "fixture",
user_agent: "fixture",
js_version: "fixture",
},
targets: [],
excluded: { disallowed_origin: 0, unsupported_url: 0, non_page: 0 },
limitations: ["Recording port; no real browser was connected."],
});
},
inspectPage: unselected,
analyzeBundle: unselected,
observeSession: unselected,
discoverWebMcpTools: unselected,
compareCaptures: unselected,
captureScreenshot: unselected,
compareScreenshots: unselected,
};
const runtime: JavaScriptRuntimeObservationPort = {
identity: () => V8_INSPECTOR_PROVIDER_IDENTITY,
listTargets: async () => {
calls.push("runtime");
return ok({
runtime: {
product: "Recording Node.js",
protocol_version: "1.0",
v8_version: null,
},
targets: [],
excluded: { unsupported_location: 0, unconnectable: 0 },
limitations: ["Recording port; no real Inspector was connected."],
});
},
observe: unselected,
};
return {
browserObservation: async () => browser,
browserScenarioCapture: async () => createBrowserScenarioProvider(),
electronObservation: async () => createElectronObservationProvider(),
electronActiveObservation: async () => createElectronScenarioProvider(),
javascriptRuntimeObservation: async () => runtime,
};
};