Tool-result surfaces
Every place MCP Hangar reads or rewrites the result of a tool call, as of core 2.29.0 plus the changes merged after it. The inventory exists because the Core Primitives working group is expected to redesign the tools/call result (content and structuredContent can carry the same output twice, and implementations diverge; SEP-3279 is one candidate shape). When a shape lands, every path below moves at once, and a path missing from this list is the one that breaks.
Paths are file:function under src/mcp_hangar/ in the core repository. "Test" names a test that exercises the path; "untested" means no test was found that drives the behaviour described, which is a gap, not a statement that it works.
The result's path, in order
- Upstream response read
- The aggregate's reading of the answer
- Response mutators
- Batch truncation and continuation
hangar_callpacking- Front-door flat return
resource_linkprojection- Task results
isErrorhandling- Redaction in events, audit and logs
- Digest pinning and
outputSchema - Audit and SIEM export
1. Upstream response read
- Where:
http_client.py(the bounded body reader that raisesResponseTooLarge), read throughdomain/model/mcp_server.py:McpServer.invoke_tool. - Reads: the upstream's response body, up to
execution.max_response_bytes(per server override available). - Writes: nothing. A body over the limit fails the call before it is held.
- Test:
tests/integration/test_an_oversized_upstream_response_reads_the_same_on_every_surface.py.
2. The aggregate's reading of the answer
- Where:
domain/model/mcp_server.py:McpServer.invoke_tool. - Reads: the JSON-RPC response. An
errormember becomes aToolInvocationError(tool_error: <message>, with the JSON-RPC code indetails). AresultwithisError: trueis a failure too: the text of its content blocks becomes the message and theToolInvocationFailedevent'serror_message, and the whole result is kept indetails.result(since #1900). Aresultotherwise is a success. - Writes:
ToolInvocationCompleted(result_size_bytes=len(str(result)))orToolInvocationFailed; the success result is returned as received. - Test: the
is_errorandrpc_errorcanary tools (tests/integration/_canary_upstream.py,_canary_harness.py);tests/integration/test_an_upstreams_failure_envelopes_reach_the_caller_unchanged.py.
3. Response mutators
- Where:
server/tools/batch/executor.py:BatchExecutor._mutate, called with("tools/call", "response", result, call_id)after a successful invoke. - Reads/writes: the interceptor
MutatorPipelinereceives the upstream result dict itself as the payload and may return a different one. The default pipeline is empty and changes nothing. A failure result never reaches it. - Observation: the built-in
application/mutators/response_truncator.py:ResponseTruncatorreadspayload["result"], a key the executor's payload (the result dict, withcontent,structuredContent,_meta) does not have, so on the executor's payload it changes nothing. It is not registered anywhere insrc; only tests construct it. A redesign should decide whether the payload shape is the result or a wrapper around it. - Test:
tests/unit/test_mutator_wiring.py,test_mutator_pipeline.py,test_response_truncator.py(the truncator against a wrapper payload, not the executor's),test_payload_shaping_is_traced.py.
4. Batch truncation and continuation
- Where:
server/tools/batch/executor.py:BatchExecutor._apply_batch_truncation, theninfrastructure/truncation/manager.py:TruncationManager.process_batch,_truncate_result,_smart_truncate_json,_truncate_dict. - Reads:
CallResult.resultof every budgeted call in ahangar_callbatch. A failure (result is None) is left alone. - Writes: over the batch budget, a structure-preserving cut of
result(keys kept, values cut, so a_metaorstructuredContentvalue can be cut like any other), the flagstruncated,truncated_reason,original_size_bytes, and acontinuation_id. The full result is stored under the calling identity. - Not applied to: a call whose caller takes the result whole (
CallSpec.whole_result): the front-door flat call and the facade'sinvoke. - Continuation read:
server/tools/continuation.py:hangar_fetch_continuationreturns the stored result in byte slices to the identity it was stored for, andhangar_delete_continuationfrees it. - Test:
tests/unit/test_truncation.py,test_a_continuation_is_confined_to_its_owner.py,tests/integration/test_a_front_door_flat_call_takes_its_result_whole.py,tests/live/test_t2_continuation_owner.py. Cutting a result that carries_metaorstructuredContent: untested.
5. hangar_call packing
- Where:
server/tools/batch/executor.py:format_result_dict, returned by thehangar_calltool inserver/tools/batch/__init__.py. - Reads:
CallResult. - Writes: one entry per call:
index,call_id,success,result,error,error_type,elapsed_ms, and when settruncated*,continuation_id,retry_metadata,upstream_result(a failure the upstream reported, since #1900). The batch dict then leaves through FastMCP's tool conversion, which serializes it twice: as a JSON text block and asstructuredContent. This is the duplicated output the working group describes, produced by Hangar for its own tools. - Test:
tests/integration/test_a_front_door_flat_call_takes_its_result_whole.py(_batch_entry),tests/integration/test_an_upstreams_failure_envelopes_reach_the_caller_unchanged.py.
6. Front-door flat return
- Where:
fastmcp_server/flat_tool_projection.py:_flat_call_tool(insideregister_flat_tool_handlers),_failed_flat_result,fastmcp_server/flat_call_tasks.py:govern_flat_call. - Reads: the one
CallResultof a single-call batch run through the same executor and gates ashangar_call. - Writes: on success, the raw result dict, which the lowlevel handler wraps (the result is whole since 2.24.0, #1611). On failure, the upstream's own failure result when it reported one (#1900), otherwise
isError: truewith Hangar's error text. A task the upstream created is answered with its SEP-2663 task result. - Test:
tests/integration/test_a_front_door_flat_call_takes_its_result_whole.py,tests/unit/test_flat_tool_reexport.py,tests/integration/test_an_upstreams_failure_envelopes_reach_the_caller_unchanged.py.
7. resource_link projection
- Where:
fastmcp_server/resource_link_read_through.py:project_result_uris, called from the flat call, fromfastmcp_server/prompt_proxy.pyforprompts/get, and fromfastmcp_server/task_relay_handlers.pyfor relayed task results. - Reads: the whole result, walking every nested node (not only
content). - Writes: in place, every
resource_linkuriand embedded resourceuriis namespaced with the owning upstream; handed-out links are remembered per tenant. A no-op outsidefront_door. - Test:
tests/unit/test_a_handed_out_resource_link_is_resolvable.py,tests/unit/test_an_upstreams_resources_are_served_through_the_front_door.py. AstructuredContentthat contains a node shaped like aresource_link: untested.
8. Task results
- Where:
server/tools/batch/relay_seam.py(reads an upstream task handle in either shape),fastmcp_server/task_relay_handlers.py(servestasks/*follow-ups). - Reads: an upstream result that is a task handle (flat
resultType: "task", or nestedtask). - Writes: the ledger entry takes the handle's fields without
resultTypeand_meta; field names are mapped to the ledger's (ttlMstottl,pollIntervalMstopollInterval). The handle given to the client is rebuilt as a SEP-2663 task result, so an upstream's_metaon a task handle does not reach the client. - Test:
tests/unit/test_a_task_a_flat_call_created_can_be_polled.py,test_task_follow_ups_link_to_origin.py._metaon a handle: untested.
9. isError handling
- Where:
domain/model/mcp_server.py:McpServer.invoke_tool(turnsisErrorinto a failure);fastmcp_server/tool_error_flag.py:flag_tool_errors(setsisError: trueon the wire for ahangar_*tool's error payload, #1669);server/tools/batch/member_health.py(anisErrorresult counts as the member answering, not as a broken member);observability/tracing.pyandfastmcp_server/flat_call_log.py(the span outcome and the call log line readisError). - Reads: the
isErrormember of a result, and the three-key error payload of ahangar_*tool. - Writes:
isErroron ahangar_*result only; an upstream's result is never relabelled. - Test:
tests/unit/test_a_tool_error_payload_is_flagged_on_the_wire.py, the canaryis_errortool.
10. Redaction in events, audit and logs
- Where:
domain/security/argument_redaction.py:redact_argumentsandhash_arguments(request arguments, in invocation events, approvals and a server's recorded env);logging_config.py(the structlog processors that scrub and bound every log field);redactor.py:OutputRedactor(a server's stderr lines and load-error reasons). - Reads/writes: arguments and log fields. No path redacts a tool result. Events carry
result_size_bytes, never the result. The one route by which result text reaches an event isToolInvocationFailed.error_message, which holds the text of anisErrorresult's content blocks;application/event_handlers/security_handler.pydeliberately leaves it out, andalert_handler.pyforwards it. - Test:
tests/unit/test_tool_arguments_are_redacted_on_every_exit.py,test_approval_argument_redaction.py,test_log_redaction.py,test_output_redactor.py. Redaction oferror_messagein the alert path: untested.
11. Digest pinning and outputSchema
- Where:
domain/services/digest_computation.py(the digest coversname,description,inputSchema,outputSchema),domain/model/tool_catalog.py(carriesoutput_schema),fastmcp_server/flat_tool_projection.py(the digest on a listing). - Reads: the tool definition of a listing.
- Writes: nothing on a result. A search of
srcfinds no code that validates a result against itsoutputSchema; the schema is pinned and listed, and a result'sstructuredContentis relayed without being checked against it. - Test:
tests/unit/domain/services/test_digest_computation.py,tests/unit/test_digest_pinning.py,tests/integration/test_a_tools_digest_reads_on_hangar_tools_and_rest.py. Result againstoutputSchema: not implemented, so nothing to test.
12. Audit and SIEM export
- Where:
compliance/cef_formatter.py,compliance/cef_exporter.py,compliance/leef_exporter.py,compliance/jsonlines_exporter.py, fed byapplication/event_handlers/audit_handler.py. - Reads: audit records built from domain events.
- Writes: event fields only. A result body is not an event field, so none is exported; a failed call's bounded
error_typeis. - Test:
tests/unit/test_compliance_exporters.py,tests/unit/test_export_formats.py.
Findings for the redesign
- The
ResponseTruncatormutator does not match the payload the executor gives mutators (section 3). - Batch truncation can cut
_metaandstructuredContentvalues independently ofcontent(section 4); no test pins what a client then sees. - An upstream's
_metaon a task handle is dropped (section 8). - Result text reaches alerts through
error_messagewithout the redaction that arguments get (section 10). hangar_callserializes its own batch result twice (section 5).