mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(mcp): add typed bounded analysis tools
This commit is contained in:
1 parent
7e4ae21d78
commit
893d2e2d05
16 files changed
+509
-100
No files matched your search
+87
-4
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
@@ -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",
|
||||
]);
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"])),
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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_ },
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user