feat(mcp): add typed bounded analysis tools

This commit is contained in:
morluto committed 2026-07-12 18:59:53 +08:00
1 parent 7e4ae21d78
commit 893d2e2d05
16 files changed
+509 -100

No files matched your search

+87 -4
View File
@@ -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)
+57 -24
View File
@@ -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<Record<string, JsonValue>>,
@@ -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));
+12 -1
View File
@@ -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",
]);
+86 -8
View File
@@ -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 = [
+46 -1
View File
@@ -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<AddressedPage, HopperProtocolError> => {
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<readonly AddressedName[], HopperProtocolError> => {
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<readonly AddressedName[], HopperProtocolError> => {
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<number, HopperProtocolError> => {
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);
+7 -1
View File
@@ -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,
),
);
};
+3 -1
View File
@@ -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);
},
);
};
+21 -10
View File
@@ -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),
);
};
+30 -1
View File
@@ -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<JsonValue, HopperError>,
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;
+20 -20
View File
@@ -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", () => {
+66 -10
View File
@@ -17,18 +17,29 @@ const PROCEDURES = {
"0x6": "prefix_TtOther",
};
const page = (values: Readonly<Record<string, string>>) => ({
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"])),
+50 -10
View File
@@ -39,6 +39,15 @@ const text = (result: CallToolResult): string => {
return content.text;
};
const structured = (result: CallToolResult): Record<string, unknown> => {
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 },
),
);
},
});
+16 -1
View File
@@ -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_ },
),
);
},
});
+3 -3
View File
@@ -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();
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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", () => {