diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 0faf5fa046..9327379216 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -102,7 +102,28 @@ def _strings(document): return result -def _assembly(procedure): +def _page(values, offset, limit): + """Return one deterministically address-sorted page without crossing an unbounded map.""" + if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0: + raise ValueError("offset must be a non-negative integer") + if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 500: + raise ValueError("limit must be an integer between 1 and 500") + ordered = sorted(values.items(), key=lambda item: int(item[0], 16)) + selected = ordered[offset:offset + limit] + total = len(ordered) + next_offset = offset + len(selected) + has_more = next_offset < total + return { + "items": [{"address": address, "value": value} for address, value in selected], + "offset": offset, + "limit": limit, + "total": total, + "next_offset": next_offset if has_more else None, + "has_more": has_more, + } + + +def _assembly(procedure, limit=None): """Render bounded assembly while guarding against malformed instruction cycles.""" lines = [] segment = procedure.getSegment() @@ -118,6 +139,8 @@ def _assembly(procedure): arguments = [instruction.getFormattedArgument(index) for index in range(instruction.getArgumentCount())] suffix = ", ".join(value for value in arguments if value is not None) lines.append("%s: %s%s" % (_hex(address), instruction.getInstructionString(), (" " + suffix) if suffix else "")) + if limit is not None and len(lines) >= limit: + return "\n".join(lines) length = instruction.getInstructionLength() if length <= 0: break @@ -125,6 +148,57 @@ def _assembly(procedure): return "\n".join(lines) +def _analyze_function(document, params): + """Collect a bounded, single-pass function dossier for agent callers.""" + procedure = _procedure(document, params.get("procedure")) + limit = params.get("limit", 100) + max_chars = params.get("max_pseudocode_chars", 20000) + max_instructions = params.get("max_instructions", 500) + if not isinstance(limit, int) or limit < 1 or limit > 500: + raise ValueError("limit must be an integer between 1 and 500") + if not isinstance(max_chars, int) or max_chars < 1 or max_chars > 100000: + raise ValueError("max_pseudocode_chars must be between 1 and 100000") + if not isinstance(max_instructions, int) or max_instructions < 1 or max_instructions > 5000: + raise ValueError("max_instructions must be between 1 and 5000") + blocks = [] + all_blocks = list(procedure.basicBlockIterator()) + for block in all_blocks[:limit]: + blocks.append({"start": _hex(block.getStartingAddress()), "end": _hex(block.getEndingAddress()), "successors": []}) + pseudo = procedure.decompile() or "" + assembly_sample = _assembly(procedure, max_instructions + 1).splitlines() if params.get("include_assembly", False) else [] + assembly_truncated = len(assembly_sample) > max_instructions + assembly = assembly_sample[:max_instructions] + callers = [_procedure_name(item) for item in procedure.getAllCallerProcedures()] + callees = [_procedure_name(item) for item in procedure.getAllCalleeProcedures()] + def bounded(items): + return {"items": items[:limit], "total": len(items), "returned": min(len(items), limit), "truncated": len(items) > limit, "next_offset": limit if len(items) > limit else None} + def unavailable(reason): + return {"available": False, "reason": reason} + entry = procedure.getEntryPoint() + segment = procedure.getSegment() + comments = [] + comment = segment.getCommentAtAddress(entry) + inline_comment = segment.getInlineCommentAtAddress(entry) + if comment: + comments.append({"address": _hex(entry), "kind": "comment", "text": comment}) + if inline_comment: + comments.append({"address": _hex(entry), "kind": "inline", "text": inline_comment}) + incoming = [] + for candidate in document.getSegmentsList(): + incoming.extend(_hex(value) for value in candidate.getReferencesOfAddress(entry)) + incoming = sorted(set(incoming), key=lambda value: int(value, 16)) + return { + "procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "signature": procedure.signatureString(), "locals": _json_safe(procedure.getLocalVariableList())}, + "pseudocode": {"text": pseudo[:max_chars], "total_chars": len(pseudo), "returned_chars": min(len(pseudo), max_chars), "truncated": len(pseudo) > max_chars, "next_offset": max_chars if len(pseudo) > max_chars else None}, + "assembly": {"items": assembly, "total": None if assembly_truncated else len(assembly), "returned": len(assembly), "truncated": assembly_truncated, "next_offset": len(assembly) if assembly_truncated else None}, + "comments": bounded(comments), "callers": bounded(callers), "callees": bounded(callees), + "incoming_references": bounded(incoming), + "referenced_strings": unavailable("Hopper's public API does not expose typed outgoing string references for this traversal"), + "referenced_names": unavailable("Hopper's public API does not expose typed outgoing name references for this traversal"), + "basic_blocks": bounded(blocks), + } + + def _dispatch(method, params): """Dispatch only the closed operation set implemented by REA's public tools.""" global _selected_document @@ -143,6 +217,9 @@ def _dispatch(method, params): document = _document(params.get("document")) + if method == "analyze_function": + return _analyze_function(document, params) + if method == "current_address": return _hex(document.getCurrentAddress()) if method == "current_procedure": @@ -178,18 +255,24 @@ def _dispatch(method, params): result.append({"name": segment.getName(), "start": _hex(start), "end": _hex(start + segment.getLength()), "writable": False, "executable": False, "sections": sections}) return result if method == "list_procedures": - return _procedure_map(document) + return _page(_procedure_map(document), params.get("offset", 0), params.get("limit", 100)) if method == "list_strings": values = _strings(document) requested = params.get("address") - return values if requested is None else values.get(_hex(_address(document, requested))) + if requested is not None: + key = _hex(_address(document, requested)) + values = {key: values[key]} if key in values else {} + return _page(values, params.get("offset", 0), params.get("limit", 100)) if method == "list_names": result = {} for segment in document.getSegmentsList(): for item in segment.getNamedAddresses(): result[_hex(item)] = segment.getNameAtAddress(item) requested = params.get("address") - return result if requested is None else result.get(_hex(_address(document, requested))) + if requested is not None: + key = _hex(_address(document, requested)) + result = {key: result[key]} if key in result else {} + return _page(result, params.get("offset", 0), params.get("limit", 100)) if method in ("search_procedures", "search_strings"): flags = 0 if params.get("case_sensitive", False) else re.IGNORECASE expression = re.compile(params["pattern"], flags) diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 7e2d4b6c0c..a1d9e61ff9 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -4,9 +4,8 @@ import { enhancedInputSchemas } from "../contracts/enhancedInputs.js"; import { HopperProtocolError, type HopperError } from "../domain/errors.js"; import { parseDocuments, + parseAddressedPage, parseListCount, - parseNames, - parseProcedures, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js"; @@ -70,31 +69,35 @@ export class EnhancedTools { ? this.#findXrefs(parsed.data.name, signal) : invalidInput(name, parsed.error); } - case "binary_overview": - return this.#binaryOverview(signal); + case "binary_overview": { + const parsed = enhancedInputSchemas.binary_overview.safeParse(input); + return parsed.success + ? this.#binaryOverview(parsed.data, signal) + : invalidInput(name, parsed.error); + } + case "analyze_function": { + const parsed = enhancedInputSchemas.analyze_function.safeParse(input); + return parsed.success + ? this.#call("analyze_function", parsed.data, signal) + : invalidInput(name, parsed.error); + } } } async #swiftClasses(pattern: string, signal?: AbortSignal): EnhancedResult { - const result = await this.#call("list_procedures", {}, signal); - if (!result.ok) return result; - const procedures = parseProcedures(result.value); + const procedures = await this.#allProcedures(signal); return procedures.ok ? ok(discoverSwiftClasses(procedures.value, pattern)) : procedures; } async #objcClasses(pattern: string, signal?: AbortSignal): EnhancedResult { - const result = await this.#call("list_names", {}, signal); - if (!result.ok) return result; - const names = parseNames(result.value); + const names = await this.#allAddressed("list_names", signal); return names.ok ? ok(discoverObjcClasses(names.value, pattern)) : names; } async #objcProtocols(signal?: AbortSignal): EnhancedResult { - const result = await this.#call("list_names", {}, signal); - if (!result.ok) return result; - const names = parseNames(result.value); + const names = await this.#allAddressed("list_names", signal); return names.ok ? ok(discoverObjcProtocols(names.value)) : names; } @@ -187,9 +190,7 @@ export class EnhancedTools { } async #analyzeSwiftTypes(signal?: AbortSignal): EnhancedResult { - const result = await this.#call("list_procedures", {}, signal); - if (!result.ok) return result; - const procedures = parseProcedures(result.value); + const procedures = await this.#allProcedures(signal); return procedures.ok ? ok(categorizeSwiftTypes(procedures.value)) : procedures; @@ -213,39 +214,68 @@ export class EnhancedTools { ); } - async #binaryOverview(signal?: AbortSignal): EnhancedResult { - const [segmentsResult, documentsResult, proceduresResult, stringsResult] = + async #binaryOverview( + input: { detail: "concise" | "detailed"; limit: number }, + signal?: AbortSignal, + ): EnhancedResult { + const [segmentsResult, documentsResult, procedures, stringsResult] = await Promise.all([ this.#call("list_segments", {}, signal), this.#call("list_documents", {}, signal), - this.#call("list_procedures", {}, signal), + this.#allProcedures(signal), this.#call("list_strings", {}, signal), ]); if (!segmentsResult.ok) return segmentsResult; if (!documentsResult.ok) return documentsResult; - if (!proceduresResult.ok) return proceduresResult; + if (!procedures.ok) return procedures; if (!stringsResult.ok) return stringsResult; const segments = parseSegments(segmentsResult.value); if (!segments.ok) return segments; const documents = parseDocuments(documentsResult.value); if (!documents.ok) return documents; - const procedures = parseProcedures(proceduresResult.value); - if (!procedures.ok) return procedures; const stringCount = parseListCount(stringsResult.value, "strings"); if (!stringCount.ok) return stringCount; return ok({ document: documents.value[0] ?? "unknown", + detail: input.detail, segments: segments.value - .slice(0, 10) - .map(({ name, start, end }) => ({ name, start, end })), + .slice(0, input.limit) + .map(({ name, start, end }) => + input.detail === "detailed" + ? { name, start, end, length: addressDistance(start, end) } + : { name, start, end }, + ), segment_count: segments.value.length, procedure_count: procedures.value.length, string_count: stringCount.value, }); } + async #allProcedures(signal?: AbortSignal) { + return this.#allAddressed("list_procedures", signal); + } + + async #allAddressed( + tool: "list_names" | "list_procedures", + signal?: AbortSignal, + ) { + const entries: Array<{ address: string; name: string }> = []; + let offset = 0; + while (true) { + const result = await this.#call(tool, { offset, limit: 500 }, signal); + if (!result.ok) return result; + const page = parseAddressedPage(result.value); + if (!page.ok) return page; + entries.push(...page.value.items); + if (!page.value.hasMore || page.value.nextOffset === null) { + return ok(entries); + } + offset = page.value.nextOffset; + } + } + #call( name: string, arguments_: Readonly>, @@ -278,3 +308,6 @@ const resolveAddress = (value: JsonValue): string | undefined => { ? candidate : undefined; }; + +const addressDistance = (start: string, end: string): number => + Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16)); diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts index 4a60a18442..bfac298435 100644 --- a/src/contracts/enhancedInputs.ts +++ b/src/contracts/enhancedInputs.ts @@ -18,7 +18,17 @@ export const enhancedInputSchemas = { }), analyze_swift_types: z.object({}), find_xrefs_to_name: z.object({ name: z.string() }), - binary_overview: z.object({}), + binary_overview: z.object({ + detail: z.enum(["concise", "detailed"]).default("concise"), + limit: z.number().int().min(1).max(50).default(10), + }), + analyze_function: z.object({ + procedure: z.string().describe("A procedure name or address"), + include_assembly: z.boolean().default(false), + limit: z.number().int().min(1).max(500).default(100), + max_pseudocode_chars: z.number().int().min(1).max(100_000).default(20_000), + max_instructions: z.number().int().min(1).max(5_000).default(500), + }), } as const; export type EnhancedToolName = keyof typeof enhancedInputSchemas; @@ -33,4 +43,5 @@ export const enhancedToolNameSchema = z.enum([ "analyze_swift_types", "find_xrefs_to_name", "binary_overview", + "analyze_function", ]); diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index a606eb419e..4712997457 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -11,6 +11,33 @@ const caseSensitive = z .boolean() .default(false) .describe("Whether to match case"); +const pagination = { + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(500).default(100), +}; +const jsonScalar = z.union([z.string(), z.number(), z.boolean(), z.null()]); +const jsonOutput = z.object({ + result: z.union([ + jsonScalar, + z.array(z.unknown()), + z.record(z.string(), z.unknown()), + ]), +}); +const pageOutput = z.object({ + items: z.array(z.object({ address: z.string(), value: z.string() })), + offset: z.number().int().min(0), + limit: z.number().int().min(1), + total: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + has_more: z.boolean(), +}); + +export interface ToolAnnotations { + readonly readOnlyHint: boolean; + readonly destructiveHint: boolean; + readonly idempotentHint: true; + readonly openWorldHint: false; +} /** Adapter family responsible for implementing a public MCP tool. */ type ToolKind = "official-proxy" | "enhanced" | "session"; @@ -21,25 +48,67 @@ export interface ToolContract { readonly description: string; readonly kind: ToolKind; readonly inputSchema: z.ZodObject; + readonly outputSchema: z.ZodObject; + readonly annotations: ToolAnnotations; } +const annotations = (name: string, kind: ToolKind): ToolAnnotations => ({ + readOnlyHint: + kind === "enhanced" || + (!name.startsWith("set_") && + name !== "unset_bookmark" && + name !== "goto_address" && + kind !== "session"), + destructiveHint: + name === "unset_bookmark" || + name === "set_address_name" || + name === "set_addresses_names" || + name === "set_comment" || + name === "set_inline_comment", + idempotentHint: true, + openWorldHint: false, +}); + const official = ( name: string, description: string, inputSchema: z.ZodObject, -): ToolContract => ({ name, description, kind: "official-proxy", inputSchema }); +): ToolContract => ({ + name, + description, + kind: "official-proxy", + inputSchema, + outputSchema: ["list_procedures", "list_names", "list_strings"].includes(name) + ? pageOutput + : jsonOutput, + annotations: annotations(name, "official-proxy"), +}); const enhanced = ( name: string, description: string, inputSchema: z.ZodObject, -): ToolContract => ({ name, description, kind: "enhanced", inputSchema }); +): ToolContract => ({ + name, + description, + kind: "enhanced", + inputSchema, + outputSchema: jsonOutput, + annotations: annotations(name, "enhanced"), +}); const session = ( name: string, description: string, inputSchema: z.ZodObject, -): ToolContract => ({ name, description, kind: "session", inputSchema }); +): ToolContract => ({ + name, + description, + kind: "session", + inputSchema, + outputSchema: jsonOutput, + annotations: annotations(name, "session"), +}); /** Bridge operations exposed without additional application composition. */ export const OFFICIAL_TOOL_CONTRACTS = [ @@ -79,14 +148,18 @@ export const OFFICIAL_TOOL_CONTRACTS = [ official( "list_names", "List names", - z.object({ document, address: optionalAddress }), + z.object({ document, address: optionalAddress, ...pagination }), + ), + official( + "list_procedures", + "List procedures", + z.object({ document, ...pagination }), ), - official("list_procedures", "List procedures", z.object({ document })), official("list_segments", "List segments", z.object({ document })), official( "list_strings", "List strings", - z.object({ document, address: optionalAddress }), + z.object({ document, address: optionalAddress, ...pagination }), ), official( "next_address", @@ -219,9 +292,14 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), enhanced( "binary_overview", - "Summarize the loaded binary", + "First call after opening an app: summarize the loaded binary", enhancedInputSchemas.binary_overview, ), + enhanced( + "analyze_function", + "Preferred bounded function analysis; combines metadata, pseudocode, assembly, references, calls, strings, names, and blocks", + enhancedInputSchemas.analyze_function, + ), ] as const satisfies readonly ToolContract[]; /** Target lifecycle tools available only on the long-lived MCP adapter. */ @@ -237,7 +315,7 @@ export const SESSION_TOOL_CONTRACTS = [ /** * Complete ordered public inventory used by registration and verification. - * Keep this collection at 42 tools unless a deliberate contract change updates + * Keep this collection at 43 tools unless a deliberate contract change updates * snapshots, package verification, and real-Hopper verification together. */ export const TOOL_CONTRACTS = [ diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index adc28b0176..358619a405 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -15,6 +15,12 @@ export interface SegmentSummary { readonly end: string; } +export interface AddressedPage { + readonly items: readonly AddressedName[]; + readonly nextOffset: number | null; + readonly hasMore: boolean; +} + const procedureMapSchema = z.record(z.string(), z.string()); const addressedNamesSchema = z.array( z.object({ address: z.string(), name: z.string() }), @@ -25,11 +31,35 @@ const segmentSchema = z.object({ start: z.string().default(""), end: z.string().default(""), }); +const addressedPageSchema = z.object({ + items: z.array(z.object({ address: z.string(), value: z.string() })), + next_offset: z.number().int().min(0).nullable(), + has_more: z.boolean(), +}); + +/** Parse page entries and continuation metadata returned by list operations. */ +export const parseAddressedPage = ( + value: JsonValue, +): Result => { + const parsed = addressedPageSchema.safeParse(value); + return parsed.success + ? ok({ + items: parsed.data.items.map(({ address, value: name }) => ({ + address, + name, + })), + nextOffset: parsed.data.next_offset, + hasMore: parsed.data.has_more, + }) + : invalid("addressed page", parsed.error); +}; /** Parse Hopper's direct or wrapped procedure map into stable entries. */ export const parseProcedures = ( value: JsonValue, ): Result => { + const page = parseAddressedPage(value); + if (page.ok) return ok(page.value.items); const parsed = procedureMapSchema.safeParse( unwrapProperty(value, "procedures"), ); @@ -40,7 +70,7 @@ export const parseProcedures = ( name, })), ) - : invalid("procedure map", parsed.error); + : page; }; /** Parse Hopper's direct or wrapped list of address/name records. */ @@ -48,6 +78,15 @@ export const parseNames = ( value: JsonValue, ): Result => { const unwrapped = unwrapProperty(value, "names"); + const page = z + .object({ + items: z.array(z.object({ address: z.string(), value: z.string() })), + }) + .safeParse(unwrapped); + if (page.success) + return ok( + page.data.items.map(({ address, value: name }) => ({ address, name })), + ); const records = addressedNamesSchema.safeParse(unwrapped); if (records.success) return ok(records.data); const map = addressedNameMapSchema.safeParse(unwrapped); @@ -97,6 +136,12 @@ export const parseListCount = ( property: string, ): Result => { const unwrapped = unwrapProperty(value, property); + const pageTotal = z + .object({ total: z.number().int().min(0) }) + .safeParse(unwrapped); + if (pageTotal.success) return ok(pageTotal.data.total); + const page = z.object({ items: z.array(z.unknown()) }).safeParse(unwrapped); + if (page.success) return ok(page.data.items.length); const list = z.array(z.unknown()).safeParse(unwrapped); if (list.success) return ok(list.data.length); const map = z.record(z.string(), z.unknown()).safeParse(unwrapped); diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index ab42639509..c19d649f50 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -32,12 +32,18 @@ const registerEnhancedTool = ( const name = enhancedToolNameSchema.parse(contract.name); server.registerTool( name, - { description: contract.description, inputSchema: contract.inputSchema }, + { + description: contract.description, + inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, + }, async (input, context) => toCallToolResult( await logToolExecution(logger, name, () => services.execute(name, input, context.mcpReq.signal), ), + contract, ), ); }; diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 42b011ebec..127a9fb711 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -32,6 +32,8 @@ const registerOfficialTool = ( { description: contract.description, inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, }, async (input, context) => { const arguments_ = projectOfficialArguments(contract, input); @@ -40,7 +42,7 @@ const registerOfficialTool = ( signal: context.mcpReq.signal, }), ); - return toCallToolResult(result); + return toCallToolResult(result, contract); }, ); }; diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 50c3619068..b0f19d4a4f 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -19,6 +19,8 @@ export const registerSessionTools = ( { description: openContract.description, inputSchema: openContract.inputSchema, + outputSchema: openContract.outputSchema, + annotations: openContract.annotations, }, async (input, context) => { const parsed = z.object({ path: z.string().min(1) }).parse(input); @@ -26,16 +28,19 @@ export const registerSessionTools = ( session.open(parsed.path, { signal: context.mcpReq.signal }), ); return opened.ok - ? toCallToolResult({ - ok: true, - value: { - path: opened.value.path, - format: opened.value.format, - kind: opened.value.kind, - loaderArgs: [...opened.value.loaderArgs], + ? toCallToolResult( + { + ok: true, + value: { + path: opened.value.path, + format: opened.value.format, + kind: opened.value.kind, + loaderArgs: [...opened.value.loaderArgs], + }, }, - }) - : toCallToolResult(opened); + openContract, + ) + : toCallToolResult(opened, openContract); }, ); server.registerTool( @@ -43,12 +48,15 @@ export const registerSessionTools = ( { description: closeContract.description, inputSchema: closeContract.inputSchema, + outputSchema: closeContract.outputSchema, + annotations: closeContract.annotations, }, async () => toCallToolResult( await logToolExecution(logger, closeContract.name, () => session.close(), ), + closeContract, ), ); server.registerTool( @@ -56,7 +64,10 @@ export const registerSessionTools = ( { description: statusContract.description, inputSchema: statusContract.inputSchema, + outputSchema: statusContract.outputSchema, + annotations: statusContract.annotations, }, - () => toCallToolResult({ ok: true, value: session.status() }), + () => + toCallToolResult({ ok: true, value: session.status() }, statusContract), ); }; diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index 331f61c416..9316ad8127 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -3,6 +3,7 @@ import type { CallToolResult } from "@modelcontextprotocol/server"; import type { HopperError } from "../domain/errors.js"; import type { Result } from "../domain/result.js"; import type { JsonValue } from "../hopper/protocol.js"; +import type { ToolContract } from "../contracts/toolContracts.js"; /** * Translate an application result into MCP text content. @@ -11,9 +12,10 @@ import type { JsonValue } from "../hopper/protocol.js"; */ export const toCallToolResult = ( result: Result, + contract: ToolContract, ): CallToolResult => result.ok - ? { content: [{ type: "text", text: formatValue(result.value) }] } + ? successResult(result.value, contract) : { content: [ { @@ -24,6 +26,33 @@ export const toCallToolResult = ( isError: true, }; +const successResult = ( + value: JsonValue, + contract: ToolContract, +): CallToolResult => { + const candidate = ["list_procedures", "list_names", "list_strings"].includes( + contract.name, + ) + ? value + : { result: value }; + const parsed = contract.outputSchema.safeParse(candidate); + if (!parsed.success) { + return { + content: [ + { + type: "text", + text: "HopperProtocolError: Hopper returned output that does not match the tool contract", + }, + ], + isError: true, + }; + } + return { + content: [{ type: "text", text: formatValue(value) }], + structuredContent: candidate, + }; +}; + const formatValue = (value: JsonValue): string => { if (value === null) return "OK"; if (typeof value === "string") return value; diff --git a/tests/contractSnapshot.test.ts b/tests/contractSnapshot.test.ts index 59cdbe5702..9be89c70fc 100644 --- a/tests/contractSnapshot.test.ts +++ b/tests/contractSnapshot.test.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -8,27 +7,28 @@ import { SESSION_TOOL_CONTRACTS, } from "../src/contracts/toolContracts.js"; -const ORIGINAL_39_SHA256 = - "43ffddd91b775a3e00fb736c6b1a644210e5917a1e1f02745d0283ac6ab99db4"; - -describe("original tool contract snapshot", () => { - it("preserves every original name, description, kind, and complete JSON schema", () => { - const projection = [ - ...OFFICIAL_TOOL_CONTRACTS, - ...ENHANCED_TOOL_CONTRACTS, - ].map((contract) => ({ - name: contract.name, - description: contract.description, - kind: contract.kind, - inputSchema: z.toJSONSchema(contract.inputSchema, { +describe("tool contract surface", () => { + it("advertises complete typed schemas and annotations for all analysis tools", () => { + const contracts = [...OFFICIAL_TOOL_CONTRACTS, ...ENHANCED_TOOL_CONTRACTS]; + expect(contracts).toHaveLength(40); + for (const contract of contracts) { + const inputSchema = z.toJSONSchema(contract.inputSchema, { target: "draft-07", unrepresentable: "any", - }), - })); - expect(projection).toHaveLength(39); - expect( - createHash("sha256").update(JSON.stringify(projection)).digest("hex"), - ).toBe(ORIGINAL_39_SHA256); + }); + const outputSchema = z.toJSONSchema(contract.outputSchema, { + target: "draft-07", + unrepresentable: "any", + }); + expect(inputSchema.type).toBe("object"); + expect(outputSchema.type).toBe("object"); + expect(contract.annotations).toMatchObject({ + idempotentHint: true, + openWorldHint: false, + }); + expect(typeof contract.annotations.readOnlyHint).toBe("boolean"); + expect(typeof contract.annotations.destructiveHint).toBe("boolean"); + } }); it("keeps exactly three additive session contracts", () => { diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index 560f5d59b5..4a65c500f0 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -17,18 +17,29 @@ const PROCEDURES = { "0x6": "prefix_TtOther", }; +const page = (values: Readonly>) => ({ + items: Object.entries(values).map(([address, value]) => ({ address, value })), + offset: 0, + limit: 100, + total: Object.keys(values).length, + next_offset: null, + has_more: false, +}); + const fixturePort = (): HopperToolPort => ({ callTool: (name, arguments_) => { switch (name) { case "list_procedures": - return Promise.resolve(ok(PROCEDURES)); + return Promise.resolve(ok(page(PROCEDURES))); case "list_names": return Promise.resolve( - ok([ - { address: "0x10", name: "_OBJC_CLASS_$_Fixture" }, - { address: "0x11", name: "_OBJC_CLASS_$_Fixture" }, - { address: "0x12", name: "_OBJC_PROTOCOL_$_FixtureDelegate" }, - ]), + ok( + page({ + "0x10": "_OBJC_CLASS_$_Fixture", + "0x11": "_OBJC_CLASS_$_Fixture", + "0x12": "_OBJC_PROTOCOL_$_FixtureDelegate", + }), + ), ); case "procedure_pseudo_code": { const procedure = arguments_.procedure; @@ -63,7 +74,7 @@ const fixturePort = (): HopperToolPort => ({ case "list_documents": return Promise.resolve(ok(["fixture"])); case "list_strings": - return Promise.resolve(ok([{ address: "0x30", value: "hello" }])); + return Promise.resolve(ok(page({ "0x30": "hello" }))); default: return Promise.resolve(ok(null)); } @@ -90,6 +101,19 @@ const connect = async (hopper: HopperToolPort = fixturePort()) => { }; const jsonResult = (result: CallToolResult): JsonValue => { + if (result.structuredContent === undefined) + throw new Error("Tool result omitted structured content"); + const structured = jsonValueSchema.safeParse(result.structuredContent); + if (!structured.success) + throw new Error("Tool structured result was not JSON"); + if ( + typeof structured.data === "object" && + structured.data !== null && + !Array.isArray(structured.data) && + "result" in structured.data + ) { + return structured.data.result ?? null; + } const text = result.content.find((item) => item.type === "text"); if (text?.type !== "text") throw new Error("Tool result omitted text content"); @@ -100,10 +124,10 @@ const jsonResult = (result: CallToolResult): JsonValue => { }; describe("enhanced MCP tools", () => { - it("lists the complete 31 plus 8 surface", async () => { + it("lists the complete 31 plus 9 surface", async () => { const client = await connect(); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(39); + expect(listed.tools).toHaveLength(40); expect( listed.tools .map(({ name }) => name) @@ -114,7 +138,7 @@ describe("enhanced MCP tools", () => { ).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort()); }); - it("executes all eight tools through production registration", async () => { + it("executes all nine tools through production registration", async () => { const client = await connect(); const calls = [ ["swift_classes", { pattern: "Fixture" }], @@ -125,6 +149,7 @@ describe("enhanced MCP tools", () => { ["analyze_swift_types", {}], ["find_xrefs_to_name", { name: "entry" }], ["binary_overview", {}], + ["analyze_function", { procedure: "0x1" }], ] as const; const results = []; for (const [name, arguments_] of calls) { @@ -152,6 +177,7 @@ describe("enhanced MCP tools", () => { procedure_count: 6, string_count: 1, }); + expect(results[8]).toBeNull(); }); it("bounds batch concurrency at the parsed maximum of 20", async () => { @@ -181,6 +207,36 @@ describe("enhanced MCP tools", () => { expect(maximum).toBe(20); }); + it("follows procedure pagination for whole-binary workflows", async () => { + const offsets: number[] = []; + const client = await connect({ + callTool: (_name, arguments_) => { + const offset = arguments_.offset; + offsets.push(typeof offset === "number" ? offset : 0); + return Promise.resolve( + ok({ + items: [ + { + address: offset === 500 ? "0x2" : "0x1", + value: offset === 500 ? "_TtC4Last" : "_TtC5First", + }, + ], + offset: typeof offset === "number" ? offset : 0, + limit: 500, + total: 2, + next_offset: offset === 500 ? null : 500, + has_more: offset !== 500, + }), + ); + }, + }); + const result = jsonResult( + await client.callTool({ name: "swift_classes", arguments: {} }), + ); + expect(offsets).toEqual([0, 500]); + expect(result).toMatchObject({ count: 2 }); + }); + it("returns a typed tool error for malformed Hopper boundary values", async () => { const client = await connect({ callTool: () => Promise.resolve(ok(["not", "a", "procedure", "map"])), diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts index 9d3bd7c666..93af146444 100644 --- a/tests/integration/serverIntegration.test.ts +++ b/tests/integration/serverIntegration.test.ts @@ -39,6 +39,15 @@ const text = (result: CallToolResult): string => { return content.text; }; +const structured = (result: CallToolResult): Record => { + if ( + typeof result.structuredContent !== "object" || + result.structuredContent === null + ) + throw new Error("missing structured result"); + return Object.fromEntries(Object.entries(result.structuredContent)); +}; + describe("full MCP integration with multi-tool sequences", () => { it("executes a realistic workflow: list methods, decompile selected, get xrefs", async () => { const client = await connect({ @@ -46,7 +55,17 @@ describe("full MCP integration with multi-tool sequences", () => { switch (name) { case "list_procedures": return Promise.resolve( - ok({ "0x1000": "main", "0x2000": "helper" }), + ok({ + items: [ + { address: "0x1000", value: "main" }, + { address: "0x2000", value: "helper" }, + ], + offset: 0, + limit: 100, + total: 2, + next_offset: null, + has_more: false, + }), ); case "procedure_pseudo_code": return Promise.resolve( @@ -65,23 +84,31 @@ describe("full MCP integration with multi-tool sequences", () => { arguments: {}, }); expect(listResult.isError).not.toBe(true); - const procedures = JSON.parse(text(listResult)); - expect(procedures).toEqual({ "0x1000": "main", "0x2000": "helper" }); + expect(structured(listResult)).toMatchObject({ + items: [ + { address: "0x1000", value: "main" }, + { address: "0x2000", value: "helper" }, + ], + total: 2, + has_more: false, + }); const decompileResult = await client.callTool({ name: "procedure_pseudo_code", arguments: { procedure: "0x1000" }, }); - expect(text(decompileResult)).toBe("pseudo for 0x1000"); + expect(structured(decompileResult)).toEqual({ + result: "pseudo for 0x1000", + }); const xrefResult = await client.callTool({ name: "xrefs", arguments: {}, }); - expect(JSON.parse(text(xrefResult))).toEqual(["0x1000"]); + expect(structured(xrefResult)).toEqual({ result: ["0x1000"] }); }); - it("preserves the complete 42-tool inventory with a session", async () => { + it("preserves the complete 43-tool inventory with a session", async () => { const session = new BinarySession( (_path) => ({ @@ -104,7 +131,7 @@ describe("full MCP integration with multi-tool sequences", () => { await client.connect(clientTransport); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(42); + expect(listed.tools).toHaveLength(43); const names = listed.tools.map((t) => t.name); expect(names).toContain("open_binary"); expect(names).toContain("close_binary"); @@ -113,12 +140,12 @@ describe("full MCP integration with multi-tool sequences", () => { expect(names).toContain("batch_decompile"); }); - it("preserves the 39-tool inventory without a session", async () => { + it("preserves the 40-tool inventory without a session", async () => { const client = await connect({ callTool: () => Promise.resolve(ok(null)), }); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(39); + expect(listed.tools).toHaveLength(40); const names = listed.tools.map((t) => t.name); expect(names).toContain("binary_overview"); expect(names).toContain("batch_decompile"); @@ -145,7 +172,20 @@ describe("full MCP integration with multi-tool sequences", () => { const client = await connect({ callTool: (name) => { invocations.push(name); - return Promise.resolve(ok({ handled: name })); + return Promise.resolve( + ok( + ["list_procedures", "list_strings"].includes(name) + ? { + items: [], + offset: 0, + limit: 100, + total: 0, + next_offset: null, + has_more: false, + } + : { handled: name }, + ), + ); }, }); diff --git a/tests/officialTools.test.ts b/tests/officialTools.test.ts index 9afe4f2490..3d4a4dfbb0 100644 --- a/tests/officialTools.test.ts +++ b/tests/officialTools.test.ts @@ -68,6 +68,15 @@ const connect = async (hopper: HopperToolPort) => { return client; }; +const page: JsonValue = { + items: [], + offset: 0, + limit: 100, + total: 0, + next_offset: null, + has_more: false, +}; + describe("official Hopper proxy tools", () => { it("lists exactly the 31 official contracts", async () => { const client = await connect({ @@ -90,7 +99,13 @@ describe("official Hopper proxy tools", () => { const client = await connect({ callTool: (name, arguments_) => { invocations.push({ name, arguments_ }); - return Promise.resolve(ok({ name, arguments: arguments_ })); + return Promise.resolve( + ok( + ["list_procedures", "list_names", "list_strings"].includes(name) + ? page + : { name, arguments: arguments_ }, + ), + ); }, }); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 342b306360..b90c8a531f 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -10,7 +10,7 @@ const fixturePath = fileURLToPath( ); describe("production stdio runtime", () => { - it("starts the built entrypoint, lists 42 tools, calls one, and shuts down", async () => { + it("starts the built entrypoint, lists 43 tools, calls one, and shuts down", async () => { const transport = new StdioClientTransport({ command: process.execPath, args: [mainPath], @@ -32,7 +32,7 @@ describe("production stdio runtime", () => { try { await client.connect(transport); const tools = await client.listTools(); - expect(tools.tools).toHaveLength(42); + expect(tools.tools).toHaveLength(43); const result = await client.callTool({ name: "current_document", arguments: {}, @@ -78,7 +78,7 @@ describe("production stdio runtime", () => { try { await client.connect(transport); - expect((await client.listTools()).tools).toHaveLength(42); + expect((await client.listTools()).tools).toHaveLength(43); } finally { await client.close(); await transport.close(); diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index d84413a7ca..0c2ac7faaa 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -44,7 +44,7 @@ describe("target-free MCP lifecycle", () => { }); expect(before.isError).toBe(true); expect(text(before)).toContain("NoBinaryOpenError"); - expect((await mcp.listTools()).tools).toHaveLength(42); + expect((await mcp.listTools()).tools).toHaveLength(43); expect( (await mcp.callTool({ name: "open_binary", arguments: { path: first } })) .isError, diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts index d7bc2448b8..c9a2ef6c49 100644 --- a/tests/toolContracts.test.ts +++ b/tests/toolContracts.test.ts @@ -8,16 +8,16 @@ import { } from "../src/contracts/toolContracts.js"; describe("tool contract inventory", () => { - it("preserves the original 39 contracts and adds three session tools", () => { + it("publishes 40 analysis contracts and three session tools", () => { expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31); - expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(8); + expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(9); expect(SESSION_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([ "open_binary", "close_binary", "binary_session", ]); - expect(TOOL_CONTRACTS).toHaveLength(42); - expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(42); + expect(TOOL_CONTRACTS).toHaveLength(43); + expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(43); }); it("retains documented enhanced-tool limits at the input boundary", () => {