From 55078b9f575528e451c888d8b7a85da2f188bbb7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:13:21 +0800 Subject: [PATCH] feat(mcp): page retained incoming reference addresses (#8591) --- docs/mcp-contracts.md | 6 ++ .../NativeObservationViews.test.ts | 97 ++++++++++--------- src/contracts/officialToolContracts.ts | 2 +- src/domain/analysisView/analysisViewResult.ts | 3 + .../analysisView/nativeObservationSchemas.ts | 1 + .../analysisView/nativeObservationView.ts | 4 +- src/domain/native/nativeObservation.ts | 2 + .../mcp/nativeObservationViewsMcp.test.ts | 32 +++++- 8 files changed, 97 insertions(+), 50 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index a08d611f80..6ac4ac4411 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -292,6 +292,12 @@ limitation and reason IDs index that page's `items.messages` dictionary. Expand those references to recover exact text, repeated messages, and absent versus empty optional fields. Failed nodes retain their original error. +Retained `xrefs` Evidence also supports `native-observation` address pages and +`native-context` source caveats. `xrefs` still returns complete Evidence by +default and does not accept `detail`; when its MCP response exceeds the budget, +follow the returned continuation to page the retained result. These addresses +remain untyped; paging does not add reference kinds or establish completeness. + These views reference complete source context through `source_context.digest` and caveat counts. Read it using the same parent with `view: {"kind":"native-context","facet":"overview"}`. The other facets are diff --git a/src/application/analysisView/NativeObservationViews.test.ts b/src/application/analysisView/NativeObservationViews.test.ts index c3e2de8d8d..be9092a4e0 100644 --- a/src/application/analysisView/NativeObservationViews.test.ts +++ b/src/application/analysisView/NativeObservationViews.test.ts @@ -121,53 +121,56 @@ const graph = (): NativeCallGraph => limitations: ["Graph caveat", "Graph caveat"], }); -it("returns useful caller/callee facts with exact source context identity and partial coverage", async () => { - const source = parent("procedure_callees", [ - "ram:0x0010", - "ram:0x0010", - "ram:0x0020", - ]); - const before = JSON.stringify(source); - const summary = await project(source, { kind: "summary" }); - expect(summary.value).toMatchObject({ - kind: "summary", - parent_evidence_id: source.evidence_id, - source_provider: source.provider, - source_parameters: source.parameters, - source_analysis_profile_digest: source.analysis_profile?.digest, - source_context: { - evidence_limitation_count: 1, - result_limitation_count: null, - }, - summary: { observations: 3 }, - items: { - type: "addresses", - rows: [ - { source_index: 0, address: "ram:0x0010" }, - { source_index: 1, address: "ram:0x0010" }, - { source_index: 2, address: "ram:0x0020" }, - ], - }, - }); - expect(summary.evidence.relationship_coverage).toEqual( - source.relationship_coverage, - ); - const page = await project(source, { - kind: "native-observation", - offset: 1, - limit: 1, - }); - expect(page.value).toMatchObject({ - items: { rows: [{ source_index: 1, address: "ram:0x0010" }] }, - coverage: { total: 3, next_offset: 2, exhausted: false }, - }); - expect(page.value).toHaveProperty( - "source_context.digest", - contextDigest(summary.value), - ); - expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":'); - expect(JSON.stringify(source)).toBe(before); -}); +it.each(["procedure_callees", "xrefs"])( + "pages %s addresses with exact source context and duplicate identity", + async (operation) => { + const source = parent(operation, [ + "ram:0x0010", + "ram:0x0010", + "ram:0x0020", + ]); + const before = JSON.stringify(source); + const summary = await project(source, { kind: "summary" }); + expect(summary.value).toMatchObject({ + kind: "summary", + parent_evidence_id: source.evidence_id, + source_provider: source.provider, + source_parameters: source.parameters, + source_analysis_profile_digest: source.analysis_profile?.digest, + source_context: { + evidence_limitation_count: 1, + result_limitation_count: null, + }, + summary: { observations: 3 }, + items: { + type: "addresses", + rows: [ + { source_index: 0, address: "ram:0x0010" }, + { source_index: 1, address: "ram:0x0010" }, + { source_index: 2, address: "ram:0x0020" }, + ], + }, + }); + expect(summary.evidence.relationship_coverage).toEqual( + source.relationship_coverage, + ); + const page = await project(source, { + kind: "native-observation", + offset: 1, + limit: 1, + }); + expect(page.value).toMatchObject({ + items: { rows: [{ source_index: 1, address: "ram:0x0010" }] }, + coverage: { total: 3, next_offset: 2, exhausted: false }, + }); + expect(page.value).toHaveProperty( + "source_context.digest", + contextDigest(summary.value), + ); + expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":'); + expect(JSON.stringify(source)).toBe(before); + }, +); it("pages complete context fields and changes its digest when source profile or caveats change", async () => { const source = parent("procedure_callers", []); diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 9d1ab1a899..45a42da025 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -260,7 +260,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "xrefs", - "Return analyzed references to a code or data address. Hopper may default to its current cursor; headless providers such as Ghidra require an explicit address. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete. Use inspect_native_instruction at the target address for typed incoming references where supported.", + "Return analyzed references to a code or data address. Hopper may default to its current cursor; headless providers such as Ghidra require an explicit address. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete. Page a retained result with inspect_analysis_view and native-observation; use native-context for source caveats. Use inspect_native_instruction at the target address for typed incoming references where supported.", z.object({ document, address: optionalAddress }), ), ] as const satisfies readonly ToolContract[]; diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index 98328d9b2c..2b5e9d3cd3 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -382,6 +382,7 @@ export const nativeObservationSummaryViewResultSchemas = { procedure_address: nativeObservationSummaryFor("procedure_address"), procedure_callees: nativeObservationSummaryFor("procedure_callees"), procedure_callers: nativeObservationSummaryFor("procedure_callers"), + xrefs: nativeObservationSummaryFor("xrefs"), resolve_containing_procedure: nativeObservationSummaryFor( "resolve_containing_procedure", ), @@ -393,6 +394,7 @@ const nativeObservationSummaryViewResultSchema = z.discriminatedUnion( nativeObservationSummaryViewResultSchemas.procedure_address, nativeObservationSummaryViewResultSchemas.procedure_callees, nativeObservationSummaryViewResultSchemas.procedure_callers, + nativeObservationSummaryViewResultSchemas.xrefs, nativeObservationSummaryViewResultSchemas.resolve_containing_procedure, nativeObservationSummaryViewResultSchemas.get_call_graph, ], @@ -412,6 +414,7 @@ const nativeObservationPageViewResultSchema = z.discriminatedUnion( nativeObservationPageFor("procedure_address"), nativeObservationPageFor("procedure_callees"), nativeObservationPageFor("procedure_callers"), + nativeObservationPageFor("xrefs"), nativeObservationPageFor("resolve_containing_procedure"), nativeObservationPageFor("get_call_graph"), ], diff --git a/src/domain/analysisView/nativeObservationSchemas.ts b/src/domain/analysisView/nativeObservationSchemas.ts index 50f42c6c99..bd526d2379 100644 --- a/src/domain/analysisView/nativeObservationSchemas.ts +++ b/src/domain/analysisView/nativeObservationSchemas.ts @@ -158,6 +158,7 @@ export const nativeObservationItemsSchemas = { procedure_address: addresses, procedure_callees: addresses, procedure_callers: addresses, + xrefs: addresses, resolve_containing_procedure: resolutions, get_call_graph: nativeGraphViewItemsSchema, }; diff --git a/src/domain/analysisView/nativeObservationView.ts b/src/domain/analysisView/nativeObservationView.ts index a532242c2d..48ef919da2 100644 --- a/src/domain/analysisView/nativeObservationView.ts +++ b/src/domain/analysisView/nativeObservationView.ts @@ -45,7 +45,8 @@ const readSource = ( ): Result => { switch (parent.operation) { case "procedure_callees": - case "procedure_callers": { + case "procedure_callers": + case "xrefs": { const read = readAddresses(parent); return read.ok ? ok({ operation: parent.operation, result: read.value }) @@ -138,6 +139,7 @@ const observationRows = ( }; case "procedure_callees": case "procedure_callers": + case "xrefs": return { parent_operation: source.operation, total: source.result.length, diff --git a/src/domain/native/nativeObservation.ts b/src/domain/native/nativeObservation.ts index e09d51e31f..3844302437 100644 --- a/src/domain/native/nativeObservation.ts +++ b/src/domain/native/nativeObservation.ts @@ -66,6 +66,7 @@ export const NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS = [ export const NATIVE_OBSERVATION_OPERATIONS = [ ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, "get_call_graph", + "xrefs", ] as const; export type NativeObservationOperation = (typeof NATIVE_OBSERVATION_OPERATIONS)[number]; @@ -74,6 +75,7 @@ export const nativeObservationSourceSchemas = { procedure_address: z.string(), procedure_callees: nativeAddressListSchema, procedure_callers: nativeAddressListSchema, + xrefs: nativeAddressListSchema, resolve_containing_procedure: containingProcedureResolutionSchema, get_call_graph: nativeCallGraphSchema, }; diff --git a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts index c5a1266865..e4222343d9 100644 --- a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts @@ -119,6 +119,33 @@ it("delivers native primitive and graph summaries with exact retained parents an ); expect(rejected.isError).toBe(true); } + const references = await call("xrefs", { address: "0x401000" }); + const source = { + kind: "retained-evidence", + evidence_id: references.evidence_id, + }; + const before = harness.calls.length; + const page = await call("inspect_analysis_view", { + source, + view: { kind: "native-observation", offset: 0, limit: 1 }, + }); + expect(page.normalized_result).toMatchObject({ + parent_evidence_id: references.evidence_id, + parent_operation: "xrefs", + items: { + type: "addresses", + rows: [{ source_index: 0, address: "0x401001" }], + }, + coverage: { total: 1, exhausted: true }, + }); + const caveats = await call("inspect_analysis_view", { + source, + view: { kind: "native-context", facet: "limitations" }, + }); + expect(caveats.normalized_result).toMatchObject({ + parent_operation: "xrefs", + }); + expect(harness.calls).toHaveLength(before); } finally { await harness.close(); } @@ -177,9 +204,12 @@ it("follows native overflow continuations through MCP without repeating provider for (const name of [ ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, "get_call_graph", + "xrefs", ] as const) { const input = - name === "get_call_graph" || name === "resolve_containing_procedure" + name === "get_call_graph" || + name === "resolve_containing_procedure" || + name === "xrefs" ? { address: "0x1000" } : { procedure: "fixture_main" }; const complete = await client.callTool({ name, arguments: input });