diff --git a/docs/capability-migration.md b/docs/capability-migration.md index 34d96b3e0..71019895d 100644 --- a/docs/capability-migration.md +++ b/docs/capability-migration.md @@ -145,3 +145,26 @@ reads, Evidence identities and deterministic bundle transfer keep their existing behavior. Snapshots still require an active target and concrete analysis profile; extracting investigation ownership does not make them a target-free record store. #721 remains responsible for any future lifetime-policy change. + +## Auxiliary source declaration pilot + +Artifact, macOS native and managed static metadata now belongs to each adapter's +`*ProviderMetadata.ts` module. The implementation and +`src/composition/auxiliaryAnalysisProviders.ts` reuse the same identity and exact +capability descriptors. The declarations reference existing AnalysisProvider +loaders; they cover these three disjoint auxiliary implementations only. Deep +candidate selection and the five typed observation ports remain separate. + +The binary runtime reads these source declarations while auxiliary construction +stays lazy. The separate managed-only entrypoint retains direct construction for +its execution-free workflow. The MCP catalog generator reads declarations without +constructing implementations, using its canonical macOS platform; actual runtime +host restrictions remain explicit. +Discovery and binary operation-kind routing now consume canonical source contracts +rather than their generated JSON artifact. No declaration imports its generated +output. Generated catalog facts and public schemas stay unchanged. + +All observation registrars resolve contracts by name. An isolated compiled-process +test reverses presentation arrays and performs actual MCP SDK calls to verify +advertised schemas and handler meaning. Reordering metadata must not select a +different operation, schema, provider or deep-provider priority. diff --git a/scripts/generate-mcp-tool-catalog.mjs b/scripts/generate-mcp-tool-catalog.mjs index c36a8279a..d66d9b4ce 100644 --- a/scripts/generate-mcp-tool-catalog.mjs +++ b/scripts/generate-mcp-tool-catalog.mjs @@ -7,9 +7,7 @@ import { McpServer } from "@modelcontextprotocol/server"; import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../dist/contracts/managedWorkflowToolContracts.js"; -import { ArtifactProvider } from "../dist/artifacts/ArtifactProvider.js"; -import { ManagedStaticProvider } from "../dist/dotnet/ManagedStaticProvider.js"; -import { NativeMacOSProvider } from "../dist/native/NativeMacOSProvider.js"; +import { auxiliaryAnalysisProviderDeclarations } from "../dist/composition/auxiliaryAnalysisProviders.js"; import { toolRegistrationOptions } from "../dist/server/toolRegistrationOptions.js"; import { ensureGeneratedFile } from "./lib/generated-file.mjs"; @@ -88,14 +86,9 @@ async function sdkToolCatalog() { await Promise.allSettled([client.close(), server.close()]); } } -const auxiliaryProviders = [ - new ArtifactProvider(CATALOG_PLATFORM), - new NativeMacOSProvider(undefined, CATALOG_PLATFORM), - new ManagedStaticProvider(), -].map((provider) => ({ - identity: provider.identity(), - capabilities: provider.capabilities(), -})); +const auxiliaryProviders = auxiliaryAnalysisProviderDeclarations( + CATALOG_PLATFORM, +).map(({ identity, capabilities }) => ({ identity, capabilities })); const canonicalizeJson = (value) => { if (Array.isArray(value)) return value.map(canonicalizeJson); diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 6840d986f..6eef810c3 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -59,7 +59,7 @@ const SOURCE_PATHS = { hopperProvider: "dist/hopper/HopperProvider.js", ghidraProvider: "dist/ghidra/GhidraProvider.js", idaProvider: "dist/ida/IdaProviderCapabilities.js", - nativeProvider: "dist/native/NativeMacOSProvider.js", + nativeProvider: "dist/native/NativeMacOSProviderMetadata.js", artifactProviders: "dist/application/InvestigationProviders.js", browserProvider: "dist/browser/CdpBrowserProvider.js", browserScenarioProvider: "dist/browser/PlaywrightBrowserScenarioProvider.js", diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts index 928c7a73a..4f168d267 100644 --- a/src/application/BinarySession.ts +++ b/src/application/BinarySession.ts @@ -18,7 +18,8 @@ import type { ProviderIdentity, } from "./AnalysisProvider.js"; import type { AnalysisOperation } from "./AnalysisProvider.js"; -import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; +import { ENHANCED_TOOL_CONTRACTS } from "../contracts/enhancedToolContracts.js"; import type { BinarySessionPort } from "./BinarySessionPort.js"; import { SessionProviderRouter, @@ -39,14 +40,10 @@ import { import { closeAnalysisClient } from "./AnalysisClientCleanup.js"; export type { BinarySessionPort } from "./BinarySessionPort.js"; const OFFICIAL_OPERATIONS: ReadonlySet = new Set( - GENERATED_MCP_TOOL_CATALOG.filter( - ({ kind }) => kind === "official-proxy", - ).map(({ name }) => name), + OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name), ); const ENHANCED_OPERATIONS: ReadonlySet = new Set( - GENERATED_MCP_TOOL_CATALOG.filter(({ kind }) => kind === "enhanced").map( - ({ name }) => name, - ), + ENHANCED_TOOL_CONTRACTS.map(({ name }) => name), ); /** diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 9f3c6bcd7..2df39ddb5 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -14,7 +14,7 @@ import { } from "../contracts/toolOutputSchemaPrimitives.js"; import type { ToolKind } from "../contracts/toolContractTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js"; +import { TOOL_CONTRACTS } from "../contracts/toolContracts.js"; import { clientRequirementsFor, NO_CLIENT_FEATURES, @@ -131,7 +131,7 @@ export const buildCapabilityInventory = ( const descriptors = new Map( status.capabilities.map((descriptor) => [descriptor.operation, descriptor]), ); - return GENERATED_MCP_TOOL_CATALOG.map((contract): ToolAvailability => { + return TOOL_CONTRACTS.map((contract): ToolAvailability => { const availability = availabilityFor({ name: contract.name, kind: contract.kind, diff --git a/src/application/LazyAnalysisProvider.ts b/src/application/LazyAnalysisProvider.ts index 1cdc3b9c8..ec2a5a457 100644 --- a/src/application/LazyAnalysisProvider.ts +++ b/src/application/LazyAnalysisProvider.ts @@ -40,12 +40,12 @@ export class LazyAnalysisProvider implements AnalysisProvider { this.#loadProvider = options.load; } - /** Return generated provider identity without loading its implementation. */ + /** Return source-declared provider identity without loading its implementation. */ identity(): ProviderIdentity { return this.#providerIdentity; } - /** Return generated capability metadata without loading its implementation. */ + /** Return source-declared capability metadata without loading its implementation. */ capabilities(): readonly CapabilityDescriptor[] { return this.#capabilityDescriptors; } diff --git a/src/application/runtime.ts b/src/application/runtime.ts index af608fb62..82e081b4f 100644 --- a/src/application/runtime.ts +++ b/src/application/runtime.ts @@ -4,10 +4,9 @@ import { HopperProvider } from "../hopper/HopperProvider.js"; import { GhidraProvider } from "../ghidra/GhidraProvider.js"; import { IdaProvider } from "../ida/IdaProvider.js"; import { silentLogger, type Logger } from "../logger.js"; -import { GENERATED_AUXILIARY_PROVIDERS } from "../generatedMcpToolCatalog.js"; +import { auxiliaryAnalysisProviderDeclarations } from "../composition/auxiliaryAnalysisProviders.js"; import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js"; import { composeBinarySession } from "./BinarySessionComposition.js"; -import { nativeHostCapabilities } from "../native/NativeHostCapabilities.js"; import { LazyAnalysisProvider } from "./LazyAnalysisProvider.js"; import { ManagedStaticProvider } from "../dotnet/ManagedStaticProvider.js"; import { SessionProviderRouter } from "./SessionProviderRouter.js"; @@ -24,47 +23,14 @@ export const createBinarySession = ( const hopper = new HopperProvider(config, logger); const ghidra = new GhidraProvider(config, logger); const ida = new IdaProvider(config); - const auxiliary = new Map( - GENERATED_AUXILIARY_PROVIDERS.map((provider) => [ - provider.identity.id, - provider, - ]), - ); - const lazyProvider = ( - id: string, - load: ConstructorParameters[0]["load"], - ) => { - const generated = auxiliary.get(id); - if (generated === undefined) - throw new TypeError(`Missing generated provider metadata for ${id}`); - return new LazyAnalysisProvider({ - ...generated, - capabilities: nativeHostCapabilities(generated.capabilities), - load, - }); - }; return composeBinarySession( new AnalysisProviderRegistry( [hopper, ghidra, ida], config.analysisProvider, ), - [ - lazyProvider("rea-artifact-graph", async () => { - const { ArtifactProvider } = - await import("../artifacts/ArtifactProvider.js"); - return new ArtifactProvider(); - }), - lazyProvider("native-macos", async () => { - const { NativeMacOSProvider } = - await import("../native/NativeMacOSProvider.js"); - return new NativeMacOSProvider(); - }), - lazyProvider("rea-dotnet-static", async () => { - const { ManagedStaticProvider } = - await import("../dotnet/ManagedStaticProvider.js"); - return new ManagedStaticProvider(); - }), - ], + auxiliaryAnalysisProviderDeclarations().map( + (declaration) => new LazyAnalysisProvider(declaration), + ), ); }; diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 370caa1af..bc0dced1b 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -27,54 +27,20 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { interfaceBuilderLimitsSchema } from "../domain/interfaceBuilderGraph.js"; import { err, ok } from "../domain/result.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; -import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js"; +import { + ARTIFACT_PROVIDER_IDENTITY as IDENTITY, + artifactCapabilities, +} from "./ArtifactProviderMetadata.js"; import { createEvidence } from "../domain/evidence.js"; import { createArtifactInspection } from "../domain/artifactInspection.js"; import { resolveArtifactIntegrityPolicy } from "../application/ArtifactInventory/policy.js"; -const IDENTITY: ProviderIdentity = Object.freeze(ARTIFACT_GRAPH_PROVIDER); - /** Read-only inventory and exclusively owned extraction provider. */ export class ArtifactProvider implements AnalysisProvider { readonly #capabilities: readonly CapabilityDescriptor[]; constructor(platform: NodeJS.Platform = process.platform) { - this.#capabilities = Object.freeze( - ARTIFACT_ANALYSIS_OPERATIONS.map((operation) => { - const common = { - provider: IDENTITY, - operation, - effects: Object.freeze({ - mutatesArtifact: false, - launchesProcess: - operation !== "decode_interface_builder" && - operation !== "inspect_keyed_archive", - mayShowUi: false, - mayAccessNetwork: false, - mayWriteFilesystem: - operation === "extract_artifact" || - operation === "inspect_artifact" || - operation === "inventory_artifact", - changesPermissions: false, - requiresRoot: false, - }), - limitations: Object.freeze([ - "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.", - "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only.", - ]), - }; - return Object.freeze( - operation === "inspect_asset_catalog" && platform !== "darwin" - ? { - ...common, - available: false as const, - availabilityCode: "unsupported_host" as const, - reason: "Apple asset catalogs require macOS assetutil.", - } - : { ...common, available: true as const, reason: null }, - ); - }), - ); + this.#capabilities = artifactCapabilities(platform); } identity(): ProviderIdentity { diff --git a/src/artifacts/ArtifactProviderMetadata.ts b/src/artifacts/ArtifactProviderMetadata.ts new file mode 100644 index 000000000..9cbe0d65e --- /dev/null +++ b/src/artifacts/ArtifactProviderMetadata.ts @@ -0,0 +1,53 @@ +import type { + CapabilityDescriptor, + ProviderIdentity, +} from "../application/AnalysisProvider.js"; +import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js"; +import { ARTIFACT_ANALYSIS_OPERATIONS } from "../contracts/artifactToolContracts.js"; + +/** Identity of artifact inventory and owned extraction observations. */ +export const ARTIFACT_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze( + ARTIFACT_GRAPH_PROVIDER, +); +const IDENTITY = ARTIFACT_PROVIDER_IDENTITY; + +/** Declare artifact coverage without constructing readers or acquiring resources. */ +export const artifactCapabilities = ( + platform: NodeJS.Platform = process.platform, +): readonly CapabilityDescriptor[] => + Object.freeze( + ARTIFACT_ANALYSIS_OPERATIONS.map((operation) => { + const common = { + provider: IDENTITY, + operation, + effects: Object.freeze({ + mutatesArtifact: false, + launchesProcess: + operation !== "decode_interface_builder" && + operation !== "inspect_keyed_archive", + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: + operation === "extract_artifact" || + operation === "inspect_artifact" || + operation === "inventory_artifact", + changesPermissions: false, + requiresRoot: false, + }), + limitations: Object.freeze([ + "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.", + "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only.", + ]), + }; + return Object.freeze( + operation === "inspect_asset_catalog" && platform !== "darwin" + ? { + ...common, + available: false as const, + availabilityCode: "unsupported_host" as const, + reason: "Apple asset catalogs require macOS assetutil.", + } + : { ...common, available: true as const, reason: null }, + ); + }), + ); diff --git a/src/composition/auxiliaryAnalysisProviders.ts b/src/composition/auxiliaryAnalysisProviders.ts new file mode 100644 index 000000000..7086817c9 --- /dev/null +++ b/src/composition/auxiliaryAnalysisProviders.ts @@ -0,0 +1,50 @@ +import type { LazyAnalysisProvider } from "../application/LazyAnalysisProvider.js"; +import { + ARTIFACT_PROVIDER_IDENTITY, + artifactCapabilities, +} from "../artifacts/ArtifactProviderMetadata.js"; +import { + MANAGED_STATIC_PROVIDER_IDENTITY, + managedStaticCapabilities, +} from "../dotnet/ManagedStaticProviderMetadata.js"; +import { + NATIVE_MACOS_PROVIDER_IDENTITY, + nativeMacOSCapabilities, +} from "../native/NativeMacOSProviderMetadata.js"; + +type AuxiliaryProviderDeclaration = ConstructorParameters< + typeof LazyAnalysisProvider +>[0]; + +/** Wire the three existing auxiliary AnalysisProvider implementations lazily. */ +export const auxiliaryAnalysisProviderDeclarations = ( + platform: NodeJS.Platform = process.platform, +): readonly AuxiliaryProviderDeclaration[] => [ + { + identity: ARTIFACT_PROVIDER_IDENTITY, + capabilities: artifactCapabilities(platform), + load: async () => { + const { ArtifactProvider } = + await import("../artifacts/ArtifactProvider.js"); + return new ArtifactProvider(platform); + }, + }, + { + identity: NATIVE_MACOS_PROVIDER_IDENTITY, + capabilities: nativeMacOSCapabilities(platform), + load: async () => { + const { NativeMacOSProvider } = + await import("../native/NativeMacOSProvider.js"); + return new NativeMacOSProvider(undefined, platform); + }, + }, + { + identity: MANAGED_STATIC_PROVIDER_IDENTITY, + capabilities: managedStaticCapabilities(), + load: async () => { + const { ManagedStaticProvider } = + await import("../dotnet/ManagedStaticProvider.js"); + return new ManagedStaticProvider(); + }, + }, +]; diff --git a/src/dotnet/ManagedStaticProvider.ts b/src/dotnet/ManagedStaticProvider.ts index a4c38471c..a61345960 100644 --- a/src/dotnet/ManagedStaticProvider.ts +++ b/src/dotnet/ManagedStaticProvider.ts @@ -11,7 +11,10 @@ import { type ExecutionOptions, type ProviderIdentity, } from "../application/AnalysisProvider.js"; -import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js"; +import { + MANAGED_STATIC_PROVIDER_IDENTITY as IDENTITY, + managedStaticCapabilities, +} from "./ManagedStaticProviderMetadata.js"; import { MANAGED_TOOL_CONTRACTS, managedArtifactInputSchema, @@ -38,30 +41,10 @@ import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js"; import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; import { inspectManagedNativeBoundariesBytes } from "./ManagedNativeBoundaryInspector.js"; -const IDENTITY: ProviderIdentity = Object.freeze(MANAGED_STATIC_PROVIDER); - /** Execution-free managed PE/CLI auxiliary provider. */ export class ManagedStaticProvider implements AnalysisProvider { - readonly #capabilities: readonly CapabilityDescriptor[] = Object.freeze( - MANAGED_TOOL_CONTRACTS.map((contract) => - Object.freeze({ - provider: IDENTITY, - operation: contract.name, - available: true as const, - reason: null, - effects: Object.freeze({ - mutatesArtifact: false, - launchesProcess: false, - mayShowUi: false, - mayAccessNetwork: false, - mayWriteFilesystem: false, - changesPermissions: false, - requiresRoot: false, - }), - limitations: limitationsFor(contract.name), - }), - ), - ); + readonly #capabilities: readonly CapabilityDescriptor[] = + managedStaticCapabilities(); identity(): ProviderIdentity { return IDENTITY; @@ -196,24 +179,6 @@ const isManagedOperation = ( ): operation is ManagedToolName => MANAGED_TOOL_CONTRACTS.some(({ name }) => name === operation); -const limitationsFor = (operation: ManagedToolName): readonly string[] => - Object.freeze( - operation === "inspect_managed_artifact" - ? [ - "This capability inventories PE/CLI identity only; inspect_managed_members admits metadata members, signatures, and CIL anchors.", - "It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code.", - ] - : operation === "inspect_managed_native_boundaries" - ? [ - "This capability inventories managed declarations for native boundaries only; native exports, thunks, and addresses require separate native-provider evidence.", - "It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code.", - ] - : [ - "This capability decodes PE/CLI metadata members, signatures, and file-backed method bodies; decompiled C# reconstruction and cross-build member comparison are separate operations.", - "It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code.", - ], - ); - const inspectManagedOperation = ( operation: ManagedToolName, parameters: Readonly>, diff --git a/src/dotnet/ManagedStaticProviderMetadata.ts b/src/dotnet/ManagedStaticProviderMetadata.ts new file mode 100644 index 000000000..09d5e970d --- /dev/null +++ b/src/dotnet/ManagedStaticProviderMetadata.ts @@ -0,0 +1,56 @@ +import type { + CapabilityDescriptor, + ProviderIdentity, +} from "../application/AnalysisProvider.js"; +import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js"; +import { + MANAGED_TOOL_CONTRACTS, + type ManagedToolName, +} from "../contracts/managedToolContracts.js"; + +/** Identity of execution-free managed metadata observations. */ +export const MANAGED_STATIC_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze( + MANAGED_STATIC_PROVIDER, +); +const IDENTITY = MANAGED_STATIC_PROVIDER_IDENTITY; + +/** Declare managed metadata coverage without opening or loading an assembly. */ +export const managedStaticCapabilities = (): readonly CapabilityDescriptor[] => + Object.freeze( + MANAGED_TOOL_CONTRACTS.map((contract) => + Object.freeze({ + provider: IDENTITY, + operation: contract.name, + available: true as const, + reason: null, + effects: Object.freeze({ + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }), + limitations: limitationsFor(contract.name), + }), + ), + ); + +const limitationsFor = (operation: ManagedToolName): readonly string[] => + Object.freeze( + operation === "inspect_managed_artifact" + ? [ + "This capability inventories PE/CLI identity only; inspect_managed_members admits metadata members, signatures, and CIL anchors.", + "It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code.", + ] + : operation === "inspect_managed_native_boundaries" + ? [ + "This capability inventories managed declarations for native boundaries only; native exports, thunks, and addresses require separate native-provider evidence.", + "It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code.", + ] + : [ + "This capability decodes PE/CLI metadata members, signatures, and file-backed method bodies; decompiled C# reconstruction and cross-build member comparison are separate operations.", + "It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code.", + ], + ); diff --git a/src/native/NativeHostCapabilities.ts b/src/native/NativeHostCapabilities.ts deleted file mode 100644 index f224a53c7..000000000 --- a/src/native/NativeHostCapabilities.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CapabilityDescriptor } from "../application/AnalysisProvider.js"; - -/** Apply native host restrictions to portable provider discovery metadata. */ -export const nativeHostCapabilities = ( - capabilities: readonly CapabilityDescriptor[], - platform: NodeJS.Platform = process.platform, -): readonly CapabilityDescriptor[] => { - if (platform === "darwin") return capabilities; - return capabilities.map((capability): CapabilityDescriptor => { - if (capability.provider.id === "native-macos") - return { - ...capability, - available: false, - availabilityCode: "unsupported_host", - reason: "Native macOS utilities require macOS.", - }; - if ( - capability.provider.id === "rea-artifact-graph" && - capability.operation === "inspect_asset_catalog" - ) - return { - ...capability, - available: false, - availabilityCode: "unsupported_host", - reason: "Apple asset catalogs require macOS assetutil.", - }; - return capability; - }); -}; diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index dc76debfe..5cc09a326 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -1,3 +1,8 @@ +import { + NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY, + nativeMacOSCapabilities, +} from "./NativeMacOSProviderMetadata.js"; +export { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacOSProviderMetadata.js"; import { inspectAppleDispatchMetadata } from "./AppleDispatchMetadata.js"; import { observeNativeUi } from "./NativeUiObservation.js"; import { dirname, isAbsolute, resolve } from "node:path"; @@ -51,14 +56,6 @@ import { inspectNativeMacho, } from "./NativeMachoInspection.js"; -/** Public identity committed by macOS-native inspection observations. */ -export const NATIVE_MACOS_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ - id: "native-macos", - name: "macOS native inspection utilities", - version: null, -}); -const IDENTITY = NATIVE_MACOS_PROVIDER_IDENTITY; - /** Read-only semantic provider composed from Xcode command-line utilities. */ export class NativeMacOSProvider implements AnalysisProvider { readonly #capabilities: readonly CapabilityDescriptor[]; @@ -67,41 +64,7 @@ export class NativeMacOSProvider implements AnalysisProvider { private readonly runner: NativeCommandRunner = new XcrunCommandRunner(), platform: NodeJS.Platform = process.platform, ) { - const available = platform === "darwin"; - this.#capabilities = Object.freeze( - [ - ...NATIVE_TOOL_CONTRACTS, - { name: "inspect_native_dispatch_metadata" as const }, - ].map((contract): CapabilityDescriptor => { - const availability = available - ? ({ available: true, reason: null } as const) - : ({ - available: false, - availabilityCode: "unsupported_host", - reason: "Native macOS utilities require macOS.", - } as const); - return Object.freeze({ - provider: IDENTITY, - operation: contract.name, - ...availability, - effects: Object.freeze({ - mutatesArtifact: false, - launchesProcess: - contract.name !== "inspect_native_dispatch_metadata", - mayShowUi: contract.name === "capture_native_ui_scenario", - mayAccessNetwork: contract.name === "capture_native_ui_scenario", - mayWriteFilesystem: - contract.name === "capture_native_ui_scenario" || - contract.name === "observe_native_ui", - changesPermissions: false, - requiresRoot: false, - }), - limitations: Object.freeze([ - "Availability and textual formats depend on the installed macOS/Xcode toolchain.", - ]), - }); - }), - ); + this.#capabilities = nativeMacOSCapabilities(platform); } identity(): ProviderIdentity { diff --git a/src/native/NativeMacOSProviderMetadata.ts b/src/native/NativeMacOSProviderMetadata.ts new file mode 100644 index 000000000..837dbcf64 --- /dev/null +++ b/src/native/NativeMacOSProviderMetadata.ts @@ -0,0 +1,53 @@ +import type { + CapabilityDescriptor, + ProviderIdentity, +} from "../application/AnalysisProvider.js"; +import { NATIVE_TOOL_CONTRACTS } from "../contracts/nativeToolContracts.js"; + +/** Public identity committed by macOS-native inspection observations. */ +export const NATIVE_MACOS_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ + id: "native-macos", + name: "macOS native inspection utilities", + version: null, +}); +const IDENTITY = NATIVE_MACOS_PROVIDER_IDENTITY; + +/** Declare native host coverage without creating a command runner. */ +export const nativeMacOSCapabilities = ( + platform: NodeJS.Platform = process.platform, +): readonly CapabilityDescriptor[] => { + const available = platform === "darwin"; + return Object.freeze( + [ + ...NATIVE_TOOL_CONTRACTS, + { name: "inspect_native_dispatch_metadata" as const }, + ].map((contract): CapabilityDescriptor => { + const availability = available + ? ({ available: true, reason: null } as const) + : ({ + available: false, + availabilityCode: "unsupported_host", + reason: "Native macOS utilities require macOS.", + } as const); + return Object.freeze({ + provider: IDENTITY, + operation: contract.name, + ...availability, + effects: Object.freeze({ + mutatesArtifact: false, + launchesProcess: contract.name !== "inspect_native_dispatch_metadata", + mayShowUi: contract.name === "capture_native_ui_scenario", + mayAccessNetwork: contract.name === "capture_native_ui_scenario", + mayWriteFilesystem: + contract.name === "capture_native_ui_scenario" || + contract.name === "observe_native_ui", + changesPermissions: false, + requiresRoot: false, + }), + limitations: Object.freeze([ + "Availability and textual formats depend on the installed macOS/Xcode toolchain.", + ]), + }); + }), + ); +}; diff --git a/src/server/registerBrowserScenarioTool.ts b/src/server/registerBrowserScenarioTool.ts index 99d016142..b1a5bb3de 100644 --- a/src/server/registerBrowserScenarioTool.ts +++ b/src/server/registerBrowserScenarioTool.ts @@ -8,7 +8,7 @@ import type { McpServer } from "@modelcontextprotocol/server"; import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js"; import { captureBrowserScenario } from "../application/BrowserScenarioCaptureService.js"; -import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "../contracts/browserScenarioToolContracts.js"; +import { toolContract } from "../contracts/toolContracts.js"; import { browserScenarioSchema } from "../domain/browserScenario.js"; import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; @@ -27,7 +27,7 @@ export const registerBrowserScenarioTool = ( server: McpServer, options: BrowserScenarioToolRegistration, ): void => { - const [contract] = BROWSER_SCENARIO_TOOL_CONTRACTS; + const contract = toolContract("capture_browser_scenario"); server.registerTool( contract.name, toolRegistrationOptions(contract), diff --git a/src/server/registerBrowserTools.ts b/src/server/registerBrowserTools.ts index 63d7ad034..498dd098b 100644 --- a/src/server/registerBrowserTools.ts +++ b/src/server/registerBrowserTools.ts @@ -18,8 +18,7 @@ import { observeWebSession, } from "../application/BrowserObservationService.js"; import type { ProgressReporter } from "../application/ProgressReporter.js"; -import { BROWSER_TOOL_CONTRACTS } from "../contracts/browserToolContracts.js"; -import type { ToolContract } from "../contracts/toolContracts.js"; +import { toolContract, type ToolContract } from "../contracts/toolContracts.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { Evidence } from "../domain/evidence.js"; import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; @@ -49,16 +48,14 @@ export const registerBrowserTools = ( server: McpServer, options: BrowserToolRegistration, ): void => { - const [ - listContract, - inspectContract, - analyzeContract, - sessionContract, - webMcpContract, - captureDiffContract, - screenshotContract, - screenshotDiffContract, - ] = BROWSER_TOOL_CONTRACTS; + const listContract = toolContract("list_browser_targets"); + const inspectContract = toolContract("inspect_web_page"); + const analyzeContract = toolContract("analyze_web_bundle"); + const sessionContract = toolContract("observe_web_session"); + const webMcpContract = toolContract("discover_webmcp_tools"); + const captureDiffContract = toolContract("compare_web_captures"); + const screenshotContract = toolContract("capture_web_screenshot"); + const screenshotDiffContract = toolContract("compare_web_screenshots"); server.registerTool( listContract.name, diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index a3f9bbf1c..9c2083207 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -16,8 +16,7 @@ import { import { analyzeJavaScriptApplicationValidated } from "../application/JavaScriptApplicationService.js"; import { reconcileJavaScriptRuntimeEvidenceValidated } from "../application/JavaScriptRuntimeReconciliationService.js"; import type { ProgressReporter } from "../application/ProgressReporter.js"; -import { ELECTRON_TOOL_CONTRACTS } from "../contracts/electronToolContracts.js"; -import type { ToolContract } from "../contracts/toolContracts.js"; +import { toolContract, type ToolContract } from "../contracts/toolContracts.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { Evidence } from "../domain/evidence.js"; import type { Result } from "../domain/result.js"; @@ -48,13 +47,11 @@ export const registerElectronTools = ( server: McpServer, options: ElectronToolRegistration, ): void => { - const [ - listContract, - inspectContract, - analyzeContract, - reconcileContract, - activeContract, - ] = ELECTRON_TOOL_CONTRACTS; + const listContract = toolContract("list_electron_targets"); + const inspectContract = toolContract("inspect_electron_page"); + const analyzeContract = toolContract("analyze_javascript_application"); + const reconcileContract = toolContract("reconcile_javascript_runtime"); + const activeContract = toolContract("capture_electron_scenario"); server.registerTool( listContract.name, diff --git a/src/server/registerJavaScriptRuntimeObservationTools.ts b/src/server/registerJavaScriptRuntimeObservationTools.ts index 72d19a6cf..759fc8600 100644 --- a/src/server/registerJavaScriptRuntimeObservationTools.ts +++ b/src/server/registerJavaScriptRuntimeObservationTools.ts @@ -11,8 +11,7 @@ import { listJavaScriptRuntimeTargets, observeJavaScriptRuntime, } from "../application/JavaScriptRuntimeObservationService.js"; -import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "../contracts/javascriptRuntimeObservationToolContracts.js"; -import type { ToolContract } from "../contracts/toolContracts.js"; +import { toolContract, type ToolContract } from "../contracts/toolContracts.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { Evidence } from "../domain/evidence.js"; import type { Result } from "../domain/result.js"; @@ -34,8 +33,8 @@ export const registerJavaScriptRuntimeObservationTools = ( server: McpServer, options: RuntimeToolRegistration, ): void => { - const [listContract, observeContract] = - JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS; + const listContract = toolContract("list_javascript_runtime_targets"); + const observeContract = toolContract("observe_javascript_runtime"); server.registerTool( listContract.name, toolRegistrationOptions(listContract), diff --git a/tests/acceptance/contractPresentation.test.ts b/tests/acceptance/contractPresentation.test.ts new file mode 100644 index 000000000..cbcc2bac3 --- /dev/null +++ b/tests/acceptance/contractPresentation.test.ts @@ -0,0 +1,63 @@ +import { execFile } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { GENERATED_MCP_TOOL_CATALOG } from "../../src/generatedMcpToolCatalog.js"; + +const execute = promisify(execFile); +const fixture = fileURLToPath( + new URL("../fixtures/contractPresentation.mjs", import.meta.url), +); +const resultSchema = z.object({ + catalog: z.array( + z.object({ + name: z.string(), + inputSchema: z.record(z.string(), z.unknown()), + outputSchema: z.record(z.string(), z.unknown()), + }), + ), + responses: z.array( + z.object({ name: z.string(), response: z.record(z.string(), z.unknown()) }), + ), +}); + +it.each(["original", "reversed"])( + "preserves advertised schemas and handler meaning with %s presentation", + async (presentation) => { + const { stdout } = await execute( + process.execPath, + [fixture, presentation], + { + timeout: 20_000, + maxBuffer: 16 * 1024 * 1024, + }, + ); + const result = resultSchema.parse(JSON.parse(stdout)); + expect(result.catalog).toHaveLength(GENERATED_MCP_TOOL_CATALOG.length); + expect(new Set(result.catalog.map(({ name }) => name))).toEqual( + new Set(GENERATED_MCP_TOOL_CATALOG.map(({ name }) => name)), + ); + for (const advertised of result.catalog) { + const canonical = GENERATED_MCP_TOOL_CATALOG.find( + ({ name }) => name === advertised.name, + ); + expect(advertised.inputSchema).toEqual(canonical?.inputSchema); + expect(advertised.outputSchema).toEqual(canonical?.outputSchema); + } + for (const { name, response } of result.responses) { + if (name === "reconcile_javascript_runtime") { + expect(response.isError).not.toBe(true); + expect(response.structuredContent).toMatchObject({ + evidence: { operation: name }, + }); + } else { + expect(response.isError, name).toBe(true); + expect(response.structuredContent, name).toMatchObject({ + error: { details: { operation: name } }, + }); + } + } + }, +); diff --git a/tests/boundary/providers/auxiliaryDeclarations.test.ts b/tests/boundary/providers/auxiliaryDeclarations.test.ts new file mode 100644 index 000000000..ea2cbdf70 --- /dev/null +++ b/tests/boundary/providers/auxiliaryDeclarations.test.ts @@ -0,0 +1,89 @@ +import { expect, it } from "vitest"; + +import { auxiliaryAnalysisProviderDeclarations } from "../../../src/composition/auxiliaryAnalysisProviders.js"; +import { LazyAnalysisProvider } from "../../../src/application/LazyAnalysisProvider.js"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { GENERATED_AUXILIARY_PROVIDERS } from "../../../src/generatedMcpToolCatalog.js"; + +it.each(["darwin", "linux", "win32"] as const)( + "preserves canonical auxiliary metadata and host facts on %s", + async (platform) => { + const declarations = auxiliaryAnalysisProviderDeclarations(platform); + expect(declarations.map(({ identity }) => identity)).toEqual( + GENERATED_AUXILIARY_PROVIDERS.map(({ identity }) => identity), + ); + const operations = new Set(); + for (const declaration of declarations) { + const canonical = GENERATED_AUXILIARY_PROVIDERS.find( + ({ identity }) => identity.id === declaration.identity.id, + ); + if (canonical === undefined) + throw new Error("Missing canonical provider"); + const expected = canonical.capabilities.map((capability) => { + if (platform === "darwin") return capability; + if (capability.provider.id === "native-macos") + return { + ...capability, + available: false, + availabilityCode: "unsupported_host", + reason: "Native macOS utilities require macOS.", + }; + if (capability.operation === "inspect_asset_catalog") + return { + ...capability, + available: false, + availabilityCode: "unsupported_host", + reason: "Apple asset catalogs require macOS assetutil.", + }; + return capability; + }); + expect(declaration.capabilities).toEqual(expected); + const first = await declaration.load(); + const second = await declaration.load(); + expect(first).not.toBe(second); + expect(first.identity()).toEqual(declaration.identity); + expect(first.capabilities()).toEqual(declaration.capabilities); + expect(second.capabilities()).toEqual(declaration.capabilities); + for (const capability of declaration.capabilities) { + expect(operations.has(capability.operation)).toBe(false); + operations.add(capability.operation); + expect(Object.isFrozen(capability)).toBe(true); + expect(Object.isFrozen(capability.effects)).toBe(true); + expect(Object.isFrozen(capability.limitations)).toBe(true); + } + } + expect(operations.has("inspect_native_dispatch_metadata")).toBe(true); + }, +); + +it("keeps declaration discovery and client creation lazy and independently owned", async () => { + const target = await parseBinaryTarget(process.execPath); + if (!target.ok) throw target.error; + for (const declaration of auxiliaryAnalysisProviderDeclarations()) { + let loads = 0; + const provider = new LazyAnalysisProvider({ + ...declaration, + load: async () => { + loads += 1; + return declaration.load(); + }, + }); + expect(provider.identity()).toEqual(declaration.identity); + expect(provider.capabilities()).toEqual(declaration.capabilities); + const client = provider.createClient(target.value); + const unusedClient = provider.createClient(target.value); + await unusedClient.close(); + expect(loads).toBe(0); + try { + expect(await client.execute("health", {})).toMatchObject({ + ok: true, + value: { provider: declaration.identity }, + }); + expect(loads).toBe(1); + expect(await client.execute("health", {})).toMatchObject({ ok: true }); + expect(loads).toBe(1); + } finally { + await client.close(); + } + } +}); diff --git a/tests/fixtures/contractPresentation.mjs b/tests/fixtures/contractPresentation.mjs new file mode 100644 index 000000000..3670f1b1b --- /dev/null +++ b/tests/fixtures/contractPresentation.mjs @@ -0,0 +1,58 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; + +import { BROWSER_TOOL_CONTRACTS } from "../../dist/contracts/browserToolContracts.js"; +import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "../../dist/contracts/browserScenarioToolContracts.js"; +import { ELECTRON_TOOL_CONTRACTS } from "../../dist/contracts/electronToolContracts.js"; +import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "../../dist/contracts/javascriptRuntimeObservationToolContracts.js"; +import { TOOL_CONTRACTS } from "../../dist/contracts/toolContracts.js"; +import { parseConfig } from "../../dist/config.js"; +import { createBinarySession } from "../../dist/application/runtime.js"; + +// Mutate only this child's presentation arrays, before importing registration. +if (process.argv[2] === "reversed") { + for (const contracts of [ + BROWSER_TOOL_CONTRACTS, + BROWSER_SCENARIO_TOOL_CONTRACTS, + ELECTRON_TOOL_CONTRACTS, + JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS, + TOOL_CONTRACTS, + ]) + contracts.reverse(); +} +const { createServer } = await import("../../dist/server/createServer.js"); +const config = parseConfig({}); +if (!config.ok) throw config.error; +const session = createBinarySession(config.value); +const server = createServer(session, session); +const client = new Client({ name: "contract-presentation", version: "1" }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const catalog = await client.listTools(); + const responses = []; + for (const contract of [ + ...BROWSER_TOOL_CONTRACTS, + ...BROWSER_SCENARIO_TOOL_CONTRACTS, + ...ELECTRON_TOOL_CONTRACTS.filter( + ({ name }) => name !== "analyze_javascript_application", + ), + ...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS, + ]) { + const example = contract.examples[0]; + if (example === undefined) + throw new Error(`Missing example for ${contract.name}`); + responses.push({ + name: contract.name, + response: await client.callTool({ + name: contract.name, + arguments: example.input, + }), + }); + } + process.stdout.write(JSON.stringify({ catalog: catalog.tools, responses })); +} finally { + await client.close(); + await server.close(); + await session.close(); +}