Skip to content

fix(binding-mcp-http): support ${result} in tool.summary - #2541

Merged
jfallows merged 8 commits into
developfrom
claude/github-issue-2535-ftuo47
Sep 4, 2026
Merged

fix(binding-mcp-http): support ${result} in tool.summary#2541
jfallows merged 8 commits into
developfrom
claude/github-issue-2535-ftuo47

Conversation

@jfallows

@jfallows jfallows commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Description

tool.summary templates could only interpolate ${result.<path>} — a scalar nested under an object key. There was no way to reference the response body's own root value, so a tool whose response is a bare scalar or array had no way to produce a templated summary at all.

  • McpHttpProxyFactory: recognize a bare ${result} (no path) as a reference to the response's own root value, captured the same way ${result.<path>} is.
  • McpHttpResults: when a watched path's value turns out to be an object or array (not a scalar), re-serialize the whole subtree into the captured text as compact JSON instead of giving up — covers nested strings (with full JSON escaping), numbers, booleans, null, and arbitrary nesting. Guard onKeyName() so a nested key inside an in-flight container capture can never arm a second, different path mid-subtree.
  • McpOpenapiCompositeGenerator: outputMaybeWrapped conflated "no response schema declared at all" with "a declared response schema that isn't object-typed" — only the second genuinely needs wrapping to satisfy an advertised outputSchema. A tool with no declared schema is no longer wrapped, so its real response streams through and a bare ${result} addresses it directly instead of a synthetic {"result": <value>} envelope forcing ${result.result}.

New and updated k3po coverage in binding-mcp-http, binding-mcp-openapi, binding-mcp-schema-registry, and binding-mcp-kafka-connect, including several tool.summary strings in the latter two that previously fell back to a generic fixed string because their responses are root-level arrays/scalars with no declared output schema.

Fixes #2535


Generated by Claude Code

resultReferences() only recognized the ${result.<path>} form, requiring
a nested object key; resolveCapturedResult() mirrored that restriction.
A response body that is itself a bare scalar had no way to feed
tool.summary at all. Recognize a bare ${result} reference (no path) as
the response's own root value: McpHttpResults arms the capture for the
empty-path reference up front (there is no KEY_NAME to trigger it),
skipping the pipeline's own START_DOCUMENT/END_DOCUMENT framing events
so the real root value is what gets captured.

Adds get_status, a tool with no declared output schema whose upstream
response is a bare JSON string, to prove the new reference end-to-end.

Fixes #2535

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvsVDMfv1iedqo667q74JT
Literal-vs-variable equals() checks flagged by checkstyle in
resolveCapturedResult(), missed in the prior commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvsVDMfv1iedqo667q74JT
McpHttpResults only captured scalar values at a ${result.<path>}
reference, giving up on an object or array with no way to fall back to
anything but an empty string. Add container capture: once a watched
path's value turns out to be an object or array, re-serialize the
whole subtree into the captured text as compact JSON instead of giving
up, covering nested strings (with full JSON escaping), numbers,
booleans, null, and arbitrarily nested containers. A path whose
in-flight container capture is still open now also blocks onKeyName()
from arming a second, different path mid-subtree, which the prior
scalar-only design never needed to guard against.

Demonstrated end-to-end with a new get_dashboard tool (a nested-object
container capture alongside scalar/null/boolean leaf references in the
same template) and by pointing list_tags' existing array-rooted
tool.summary at the new bare ${result} capture.

This is what makes ${result.result} usable against mcp-openapi's
wrap-as-{"result":<value>} envelope for a non-object OpenAPI response,
so seven generic, non-templated tool.summary strings across
binding-mcp-schema-registry and binding-mcp-kafka-connect (their
responses are root-level arrays or a bare integer) now interpolate the
real response instead.

Also fixes a k3po script-escaping bug this surfaced: a k3po single- or
double-quoted string literal treats backslash as its own escape
character, so embedding a literal backslash in expected wire bytes (as
several pre-existing and new .rpt scripts needed to, once summaries
started containing JSON-escaped content) requires doubling it in the
script source -- a bare backslash-quote sequence is silently consumed
by k3po's own lexer before the engine ever sees the script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvsVDMfv1iedqo667q74JT
…emands it

outputMaybeWrapped treated "no declared response schema at all" the
same as "a declared response schema that isn't object-typed" -- both
made hasObjectOutputSchema() return false. Only the second case is a
real constraint: MCP's outputSchema (and the structuredContent it
describes) must be an object, so a non-object *advertised* schema
needs the response body wrapped to match it. When no response schema
is declared at all, no outputSchema is ever advertised (the derived
"<name>-output" catalog subject is only registered when the OpenAPI
operation's response actually declares one) -- so there is nothing for
structuredContent to conform to, and wrapping served no purpose beyond
its own side effect of hiding the real root value from a summary
template's bare ${result} behind a synthetic {"result": <value>}
envelope.

Add hasDeclaredOutputSchema() as an explicit precondition alongside the
existing object-schema check, so a tool with no declared response
schema is no longer wrapped and a bare ${result} in its tool.summary
addresses the real response.

Restores the seven binding-mcp-schema-registry and
binding-mcp-kafka-connect tool.summary strings updated in the prior
commit to plain ${result} instead of the ${result.result} workaround
their wrapping required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvsVDMfv1iedqo667q74JT
…e-2535-ftuo47

# Conflicts:
#	runtime/binding-mcp-schema-registry/src/main/resources/io/aklivity/zilla/runtime/binding/mcp/schema/registry/internal/schema/karapace-schema-registry.openapi.json
…-registry tools

Merging develop brought in new tests written against the pre-fix
wrapping behavior: two schema-registry "permanent delete" script
variants, and six kafka-connect operations (pause/stop/restart/resume/
delete connector, restart connector task) whose empty response bodies
parse to JSON null. All eight have no declared response schema, so the
prior commit's outputMaybeWrapped fix un-wraps them the same way it
did the original seven -- their structuredContent is now the real
value (null, or the real array/scalar for the two permanent-delete
scripts) instead of a synthetic {"result": <value>} envelope. Update
their expected structuredContent to match; none of these eight
reference ${result...} in their summary, so no summary text changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvsVDMfv1iedqo667q74JT
…protocol compliance

The prior commit's outputMaybeWrapped fix unwrapped structuredContent for any
tool with no declared response schema, on the theory that wrapping served no
purpose without an advertised outputSchema to conform to. That overlooked a
harder constraint: the MCP protocol itself requires structuredContent to
always be a JSON object (confirmed via the official TypeScript SDK's
CallToolResultSchema, which types structuredContent as z.record(...)). An
undeclared-schema response can still be a bare array or scalar at runtime, so
leaving it unwrapped produces a response real MCP clients reject outright --
exactly what broke the mcp.proxy example's kafka_sr__list_subjects call in CI.

Revert outputMaybeWrapped to wrap whenever the response isn't provably an
object (declared or not), and remove hasDeclaredOutputSchema along with it.

Fix the actual awkwardness -- a wrapped tool's tool.summary needing
${result.result} instead of ${result} -- at the layer that owns it instead:
McpHttpProxyFactory now tracks whether the active tool is wrapped and
transparently resolves a bare ${result} against the wrap envelope's "result"
key rather than the streamed root, so the wrapper stays invisible to summary
authors without touching the wire format. newToolResultReferences remaps the
same bare capture path so McpHttpResults captures the right key.

Restores the corresponding binding-mcp-schema-registry and
binding-mcp-kafka-connect spec scripts to expect wrapped structuredContent
(matching the protocol-correct behavior) while keeping their tool.summary
text on the clean, unwrapped ${result} value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvsVDMfv1iedqo667q74JT
@jfallows
jfallows merged commit 2c1a062 into develop Sep 4, 2026
43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

binding-mcp-http: tool.summary template has no way to reference the response's own root value

2 participants