diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 58d7d4e2c..f235c740c 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -48,6 +48,7 @@ const status = ( options: { readonly open?: boolean; readonly kind?: "executable" | "database" | "archive" | "artifact"; + readonly format?: string; readonly capabilities?: readonly { readonly operation: string; readonly available: boolean; @@ -58,6 +59,7 @@ const status = ( ) => ({ open: options.open ?? false, ...(options.kind === undefined ? {} : { kind: options.kind }), + ...(options.format === undefined ? {} : { format: options.format }), capabilities: (options.capabilities ?? []).map((capability) => ({ ...capability, availability_code: capability.available @@ -237,6 +239,129 @@ describe("capability inventory: provider status", () => { }); }); +describe("capability inventory: active-target operations", () => { + const ACTIVE_TARGET_TOOLS = [ + "inspect_macho", + "inspect_signature", + "list_architectures", + "demangle_swift", + "inspect_artifact", + "extract_artifact", + ] as const; + const declared = ( + names: readonly string[], + availability: { + readonly available: boolean; + readonly reason: string | null; + } = { + available: true, + reason: null, + }, + ) => names.map((operation) => ({ operation, ...availability })); + const reasons = ( + sessionStatus: ReturnType, + names: readonly string[], + policy = enabledPolicy, + ) => + Object.fromEntries( + names.map((name) => [name, entry(name, sessionStatus, policy).reason]), + ); + + it("requires an open target even when the provider declares the operation", () => { + const closed = status({ capabilities: declared(ACTIVE_TARGET_TOOLS) }); + for (const name of ACTIVE_TARGET_TOOLS) + expect(entry(name, closed)).toMatchObject({ + available: false, + reason: "target_required", + remediation: expect.stringContaining("open_binary"), + }); + }); + + it("keeps explicit-path operations available without a target", () => { + const explicitPath = [ + "inspect_managed_artifact", + "inspect_binary_layout", + "inspect_recorded_crash", + "inspect_evm_interface", + ]; + expect( + reasons(status(), explicitPath, { + ...enabledPolicy, + binaryLayoutEnabled: true, + recordedCrashEnabled: true, + evmInterfaceEnabled: true, + }), + ).toEqual( + Object.fromEntries(explicitPath.map((name) => [name, "available"])), + ); + }); + + it("reserves Mach-O operations for an open Mach-O executable", () => { + const capabilities = declared(ACTIVE_TARGET_TOOLS); + const machO = status({ + open: true, + kind: "executable", + format: "mach-o", + capabilities, + }); + const zip = status({ + open: true, + kind: "archive", + format: "zip", + capabilities, + }); + const elf = status({ + open: true, + kind: "executable", + format: "elf", + capabilities, + }); + expect(reasons(machO, ACTIVE_TARGET_TOOLS)).toEqual( + Object.fromEntries( + ACTIVE_TARGET_TOOLS.map((name) => [name, "available"]), + ), + ); + for (const target of [zip, elf]) + expect(reasons(target, ACTIVE_TARGET_TOOLS)).toEqual({ + inspect_macho: "target_unsupported", + inspect_signature: "available", + list_architectures: "target_unsupported", + demangle_swift: "available", + inspect_artifact: "available", + extract_artifact: "available", + }); + expect(entry("inspect_macho", zip).remediation).toContain("Mach-O"); + }); + + it("reports an unsupported host before asking for a target", () => { + const capabilities = declared(["inspect_macho"], { + available: false, + reason: "Native macOS utilities require macOS.", + }).map((capability) => ({ + ...capability, + availability_code: "unsupported_host" as const, + })); + for (const sessionStatus of [ + status({ capabilities }), + status({ open: true, kind: "archive", format: "zip", capabilities }), + ]) + expect(entry("inspect_macho", sessionStatus)).toMatchObject({ + reason: "unsupported_host", + remediation: "Native macOS utilities require macOS.", + }); + const composed = capabilities.map((capability) => ({ + ...capability, + operation: "inspect_native_dispatch_metadata", + })); + expect( + entry( + "inspect_native_dispatch_metadata", + status({ capabilities: composed }), + ).reason, + ).toBe("target_required"); + }); +}); + describe("capability inventory: caller guidance", () => { it("gives every unavailable public tool an actionable remediation", () => { const inventory = buildCapabilityInventory(status(), { diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 4f85c9aeb..56765f005 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -78,6 +78,7 @@ type AvailabilityContext = { | "archive" | "artifact" | undefined; + readonly targetFormat: string | undefined; readonly descriptors: ReadonlyMap; readonly policy: AvailabilityPolicy; }; @@ -112,6 +113,23 @@ const ENHANCED_REQUIREMENTS: Readonly> = { ], }; +/** Contract kinds whose operations run on the active session target. */ +const ACTIVE_TARGET_KINDS: ReadonlySet = new Set([ + "official-proxy", + "enhanced", + "native-provider", + "artifact-provider", +]); + +/** Native contracts defined only for an active Mach-O executable. */ +const MACHO_TARGET_OPERATIONS: ReadonlySet = new Set([ + "inspect_macho", + "list_architectures", + "observe_native_calls", + "observe_native_ui", + "capture_native_ui_scenario", +]); + const NAVIGATION_CONTEXT_MODES = [ { name: "current_selection", @@ -143,6 +161,7 @@ export const buildCapabilityInventory = ( kind: contract.kind, targetOpen: status.open, targetKind: status.kind, + targetFormat: status.format, descriptors, policy, }); @@ -211,6 +230,8 @@ const availabilityFor = (context: AvailabilityContext): Availability => { }; const workflowAvailability = workflowAvailabilityFor(context); if (workflowAvailability !== null) return workflowAvailability; + const hostDecision = hostAvailability(context); + if (hostDecision !== null) return hostDecision; const targetDecision = targetAvailability(context); if (targetDecision !== null) return targetDecision; return providerAvailability(context); @@ -401,12 +422,32 @@ const browserProviderAvailability = ( }; }; +/** + * Opening another target cannot make an operation run on an unsupported host. + * Composed operations are decided by their requirements, which another + * provider may satisfy. + */ +const hostAvailability = ({ + descriptors, + kind, + name, +}: AvailabilityContext): Availability | null => { + if (kind === "enhanced") return null; + const descriptor = descriptors.get(name); + return descriptor?.available === false && + descriptor.availability_code === "unsupported_host" + ? { reason: "unsupported_host", remediation: descriptor.reason } + : null; +}; + const targetAvailability = ({ + name, kind, targetKind, + targetFormat, targetOpen, }: AvailabilityContext): Availability | null => { - if (!targetOpen && (kind === "official-proxy" || kind === "enhanced")) + if (!targetOpen && ACTIVE_TARGET_KINDS.has(kind)) return { reason: "target_required", remediation: "Call open_binary with a supported local target.", @@ -422,6 +463,16 @@ const targetAvailability = ({ remediation: "Inventory or extract a native executable, then call open_binary on that executable.", }; + if ( + targetOpen && + MACHO_TARGET_OPERATIONS.has(name) && + (targetKind !== "executable" || targetFormat !== "mach-o") + ) + return { + reason: "target_unsupported", + remediation: + "Call open_binary with a Mach-O executable or an app bundle whose main executable is Mach-O.", + }; return null; }; @@ -445,14 +496,6 @@ const providerAvailability = ({ remediation: "Install or configure a provider that declares this operation.", }; - if ( - !descriptor.available && - descriptor.availability_code === "unsupported_host" - ) - return { - reason: "unsupported_host", - remediation: descriptor.reason, - }; return descriptor.available ? { reason: "available", remediation: null } : { diff --git a/tests/boundary/mcp/serverIdentity.test.ts b/tests/boundary/mcp/serverIdentity.test.ts index 2305a346e..12c1e67a5 100644 --- a/tests/boundary/mcp/serverIdentity.test.ts +++ b/tests/boundary/mcp/serverIdentity.test.ts @@ -1,9 +1,13 @@ -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { machoImage } from "../../../src/artifacts/apple/MachoImage.fixture.js"; import { CATALOG_IDENTITY, CLI_COMMAND_NAMES, @@ -21,11 +25,17 @@ import type { CapabilityDescriptor, } from "../../../src/application/AnalysisProvider.js"; -const availabilityProvider = (): AnalysisProvider => { +const availabilityProvider = ( + operations: readonly CapabilityDescriptor["operation"][] = [ + "current_address", + ], +): AnalysisProvider => { const identity = { id: "fixture", name: "Fixture", version: "1" }; - const capability: CapabilityDescriptor = { + const capability = ( + operation: CapabilityDescriptor["operation"], + ): CapabilityDescriptor => ({ provider: identity, - operation: "current_address", + operation, available: true, reason: null, effects: { @@ -38,10 +48,10 @@ const availabilityProvider = (): AnalysisProvider => { requiresRoot: false, }, limitations: [], - }; + }); return { identity: () => identity, - capabilities: () => [capability], + capabilities: () => operations.map(capability), createClient: () => ({ health: () => Promise.resolve(), execute: () => Promise.resolve(observed(null)), @@ -248,6 +258,101 @@ describe("live server identity over MCP", () => { }, 10_000); }); +describe("active-target availability over MCP", () => { + it("advertises active-target tools only when a matching target is open", async () => { + const session = createTestBinarySession( + availabilityProvider(["inspect_macho", "inspect_artifact"]), + ); + const server = createServer(session, session); + const client = new Client({ name: "availability-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const directory = await createTestTempDirectory("rea-target-availability-"); + const executable = join(directory, "tool"); + const plist = join(directory, "Info.plist"); + await writeFile(executable, machoImage({})); + await writeFile(plist, PLIST_XML); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const listed = await toolNames(client); + expect(await activeTargetReasons(client)).toEqual({ + inspect_macho: "target_required", + inspect_artifact: "target_required", + inspect_managed_artifact: "available", + }); + const call = await client.callTool({ + name: "inspect_macho", + arguments: {}, + }); + expect(call.structuredContent).toMatchObject({ + error: { code: "target_unavailable" }, + }); + await openTarget(client, plist); + expect(await activeTargetReasons(client)).toEqual({ + inspect_macho: "target_unsupported", + inspect_artifact: "available", + inspect_managed_artifact: "available", + }); + await client.callTool({ name: "close_binary", arguments: {} }); + await openTarget(client, executable); + expect(await activeTargetReasons(client)).toEqual({ + inspect_macho: "available", + inspect_artifact: "available", + inspect_managed_artifact: "available", + }); + await client.callTool({ name: "close_binary", arguments: {} }); + expect((await activeTargetReasons(client)).inspect_macho).toBe( + "target_required", + ); + expect(await toolNames(client)).toEqual(listed); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }, 10_000); +}); + +const PLIST_XML = + 'CFBundleNameFixture'; + +const toolNames = async (client: Client): Promise => + (await client.listTools()).tools.map(({ name }) => name); + +const openTarget = async (client: Client, path: string): Promise => { + const opened = await client.callTool({ + name: "open_binary", + arguments: { path }, + }); + expect(opened.isError ?? false).toBe(false); +}; + +const activeTargetReasons = async ( + client: Client, +): Promise> => { + const status = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + const availability = z + .object({ + result: z.object({ + tool_availability: z.array( + z.object({ name: z.string(), reason: z.string() }).loose(), + ), + }), + }) + .parse(status.structuredContent).result.tool_availability; + return Object.fromEntries( + ["inspect_macho", "inspect_artifact", "inspect_managed_artifact"].map( + (name) => [name, availability.find((tool) => tool.name === name)?.reason], + ), + ); +}; + const assertLiveIdentity = async (client: Client): Promise => { const identity = await client.callTool({ name: "binary_session",