Skip to content

feat(mcptoolset): surface elicitation and tool result _meta#1168

Open
QuentinBisson wants to merge 2 commits into
google:mainfrom
QuentinBisson:feat/mcptoolset-elicitation-meta
Open

feat(mcptoolset): surface elicitation and tool result _meta#1168
QuentinBisson wants to merge 2 commits into
google:mainfrom
QuentinBisson:feat/mcptoolset-elicitation-meta

Conversation

@QuentinBisson

Copy link
Copy Markdown

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

Problem:

mcptoolset flattens every CallToolResult to {"output": ...} and drops the result's _meta field, and there is no way to handle elicitation requests: servers using URL-mode elicitation (SEP-1036, spec 2025-11-25) for out-of-band flows such as per-user OAuth challenges fail opaquely inside the toolset (client does not support "url" elicitation). go-sdk already ships the full client surface (ClientOptions.ElicitationHandler, ElicitationCompleteHandler, URLElicitationCapabilities), so this is purely mcptoolset plumbing.

Solution:

Two independent pieces, as proposed in #1165:

  1. Config.ElicitationHandler and Config.ElicitationCompleteHandler, applied to the default MCP client. Setting a handler declares both form and URL elicitation capabilities (the SDK's inferred capability covers form mode only; RootsV2 preserves the default roots capability that setting Capabilities would otherwise disable). Combining the handlers with a custom Config.Client returns an error from New, since handlers must be configured in that client's own mcp.ClientOptions.
  2. Result _meta passthrough: when a tool result carries Meta, it is included in the returned function response map under the "_meta" key, mirroring the raw MCP serialization. Results without Meta are unchanged.

I went with the reserved-key shape for _meta rather than an OnToolResult hook (the other option floated in #1165) because it is data-only and involves no new callback surface; happy to rework to the hook shape if preferred.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

New tests in tool/mcptoolset/set_test.go, all running real MCP protocol traffic over in-memory transports against a real mcp.Server:

  • TestCallToolMetaPassthrough: text and structured results with _meta preserved; no _meta key when the result has none.
  • TestElicitationHandler: server raises a URL-mode elicitation mid tool call; the configured handler receives the URL and the call completes.
  • TestNewRejectsElicitationHandlerWithCustomClient: error when combining handlers with a custom client.
$ go test ./tool/mcptoolset/
ok  	google.golang.org/adk/v2/tool/mcptoolset	(24 tests passed)

Manual End-to-End (E2E) Tests:

The elicitation test drives the full path end-to-end (real mcp.Serverelicitation/create with mode: "url" → client handler → tool result), which is the same wire sequence an MCP gateway produces. We (Giant Swarm) also plan to run this against our MCP gateway (per-user OAuth to backends, challenges mapped to the A2A auth-required task state) and can report back on the PR.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

Python twin (elicitation callback only; meta already survives there): google/adk-python#6422 / google/adk-python#6423.

Add ElicitationHandler and ElicitationCompleteHandler to
mcptoolset.Config, applied to the default MCP client so servers can
use elicitation/create (including URL-mode elicitation per SEP-1036)
during tool calls. The client declares both form and URL elicitation
capabilities when a handler is set; combining the handlers with a
custom Client is rejected since handlers must be configured on the
client itself.

Preserve the tool result _meta field in the function response map
under the "_meta" key (mirroring raw MCP serialization) instead of
dropping it, so server-attached metadata such as auth challenges
reaches callbacks and the embedding application.

@karolpiotrowicz karolpiotrowicz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, @QuentinBisson! I know this is still a draft, but since it's linked to #1165 I took an early look — and it's already in really good shape, so I wanted to share some feedback now rather than make you wait. Appreciate the work you've put in here, especially the clean split into the two independent pieces (_meta passthrough + elicitation plumbing) and the in-memory-transport tests running real protocol traffic against a live server.

I pulled the branch and it holds up well: the full test suite passes, -race is clean on the package, and it's backward compatible (_meta is only added when present, and the two new Config fields are purely additive).

None of this is urgent given the draft status — just flagging it early so it's easy to fold in. One thing I'd want to resolve before it comes out of draft, plus a couple of doc clarifications and an optional design question:

1. ElicitationCompleteHandler set on its own advertises a capability the client can't service. New enters the capability branch on ElicitationHandler != nil || ElicitationCompleteHandler != nil, so setting only ElicitationCompleteHandler advertises form+URL elicitation while ElicitationHandler stays nil — and the SDK then rejects any incoming elicitation/create with "client does not support elicitation". Since a completion notification can't arrive unless an elicitation was created first, this combination is effectively unusable. I'd suggest failing fast in New (details inline).

2. Doc accuracy on _meta. The functionResponse comment says the metadata reaches "callbacks and the embedding application" — worth adding that it's also included in the function response returned to the model (and therefore persisted in session/traces). That's the intended behavior per #1165, but it's the most consequential consumer, so it's good to spell out.

3. (Optional / your call) Opt-in vs. always-on for _meta. Because the metadata now flows to the model for every server that emits it, users of meta-emitting servers will start seeing it surface without opting in. The reserved-key shape you chose is faithful to #1165 — just flagging the always-on aspect in case you'd prefer a flag or the OnToolResult hook that was also floated.

A few optional test/robustness follow-ups can wait until it's marked ready. Thanks again for driving this — happy to review again whenever you flip it out of draft.

Comment thread tool/mcptoolset/set.go
if cfg.ElicitationHandler != nil || cfg.ElicitationCompleteHandler != nil {
if cfg.Client != nil {
return nil, fmt.Errorf("mcptoolset: ElicitationHandler and ElicitationCompleteHandler cannot be combined with a custom Client; set them in the client's mcp.ClientOptions instead")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The capability branch is gated on either handler being set, but only ElicitationHandler can actually service an incoming elicitation/create. If a caller sets only ElicitationCompleteHandler, the client advertises form+URL elicitation yet the SDK rejects the request at runtime with "client does not support elicitation" — and a completion notification can't arrive without a prior elicitation anyway, so this config can't work.

Simplest fix is to fail fast in New, e.g. right after the custom-client check:

Suggested change
}
}
if cfg.ElicitationHandler == nil {
return nil, fmt.Errorf("mcptoolset: ElicitationCompleteHandler requires ElicitationHandler to be set")
}

(Alternatively, gate just the Capabilities.Elicitation advertisement on cfg.ElicitationHandler != nil — but since the complete-handler-only case is inert, erroring is clearer.)

Comment thread tool/mcptoolset/tool.go Outdated
Comment on lines +173 to +177
// functionResponse builds the function response map for a tool result.
// The result's _meta field is preserved under the "_meta" key, mirroring the
// raw MCP serialization, so that metadata attached by the server (e.g. auth
// challenges from MCP gateways) reaches callbacks and the embedding
// application instead of being silently dropped.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small doc clarification: this map is the function response returned to the model, so _meta reaches the LLM (and is persisted to session/traces), not only "callbacks and the embedding application." Could you extend the comment to say so? It's the intended behavior per #1165, but the model being a consumer is the important part for anyone reading this later.

…rify _meta doc

Fail fast in New when ElicitationCompleteHandler is set without
ElicitationHandler: the client cannot service an elicitation/create and a
completion notification cannot arrive without a prior elicitation, so the
config is inert.

Clarify that _meta in the function response reaches the model and is
persisted to session and traces.
@QuentinBisson

Copy link
Copy Markdown
Author

Thanks for the early look, @karolpiotrowicz, and for pulling the branch and running -race — really appreciate the thoroughness while it's still draft.

1. ElicitationCompleteHandler without ElicitationHandler — good catch, fixed. New now fails fast right after the custom-client check: an ElicitationCompleteHandler with no ElicitationHandler returns an error, since the client can't service an elicitation/create and a completion can't arrive without a prior elicitation. Added a field-doc note and a regression test (TestNewRejectsElicitationCompleteHandlerWithoutElicitationHandler). I went with erroring over silently gating the capability advertisement, per your suggestion — the inert config should be loud.

2. _meta doc accuracy — fixed. The functionResponse comment now spells out that the map is the function response returned to the model, so _meta reaches the LLM and is persisted to session/traces, on top of callbacks and the embedding application.

3. Always-on vs opt-in for _meta — I'd like to keep it always-on. The Python twin (google/adk-python) already does this: _run_async_impl returns response.model_dump(exclude_none=True), i.e. the full CallToolResult including _meta, unconditionally whenever the server emits it — no flag, no hook. Making Go opt-in would diverge from the established twin for no correctness gain, and the reserved-key shape is what #1165 settled on. If the always-on surface turns out to be a problem in practice, an OnToolResult hook is a clean additive follow-up rather than something that needs to block this. Flagging the always-on behavior explicitly here so it's on record as a conscious decision.

Both fixes are pushed. Marking ready for review.

@QuentinBisson
QuentinBisson marked this pull request as ready for review July 21, 2026 17:25
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.

mcptoolset: tool result _meta is discarded, leaving no path for MCP server auth challenges (e.g. SEP-1036 URL elicitation)

2 participants