Feat: expose an ADK agent as an MCP server (toMcpServer) - #580
Open
AmaadMartin wants to merge 4 commits into
Open
Feat: expose an ADK agent as an MCP server (toMcpServer)#580AmaadMartin wants to merge 4 commits into
AmaadMartin wants to merge 4 commits into
Conversation
added 4 commits
August 3, 2026 09:22
Ports adk-python's to_mcp_server to TypeScript. The returned McpServer registers the whole agent as one MCP tool so any MCP host can drive it without importing ADK, and preserves multimodal output by mapping ADK parts to MCP text/image/audio/resource blocks.
Drives the registered tool over a linked in-process MCP transport pair so the JSON-RPC framing, schema validation and progress notifications are exercised for real, and covers the session lifecycle and every content mapping branch. 100% line and branch coverage of the new module.
An untyped .catch parameter is implicitly any; narrowing it to unknown keeps the rethrow honest without changing behaviour.
buildRunner re-assembled by hand what InMemoryRunner already provides. Its only extra was an InMemoryCredentialService, which was the sole credential service instantiation in core/src and has no reader there: no other entry point wires one (not toA2a, not InMemoryRunner, not the CLI). Service wiring never crosses the MCP boundary, so local convention wins over the reference implementation here. createSessionId was an eight-line wrapper with a single caller, so it folds into the memoised promise it fed.
AmaadMartin
added a commit
that referenced
this pull request
Aug 4, 2026
…Tools (adk-python parity) (#580) * Add canUseOutputSchemaWithTools predicate (adk-python parity) Ports can_use_output_schema_with_tools from adk-python. It reports whether a model can natively accept an output schema alongside tools, which is more reliable than the prompt-based set_model_response workaround. Composed from the existing getGoogleLlmVariant() and isGemini2OrAbove() helpers, so it recognises Gemini 2.0+ by numeric version only. Gemini Early Access Program names, which the Python predicate also matches, are therefore not recognised; that gap lives in the shared isGemini2OrAbove() predicate and is tracked separately. The helper is internal and intentionally not exported from the package barrels, matching how getGoogleLlmVariant() is treated. * Gate the set_model_response workaround on canUseOutputSchemaWithTools adk-js unconditionally fell back to the synthetic set_model_response tool whenever an agent had both an outputSchema and tools. adk-python applies that workaround only when the model cannot natively take a response schema alongside tools. On Vertex AI with Gemini 2.0+ the native path works and is more reliable. All three sites that key off "outputSchema and tools" move together: - LlmAgent.runOneStepAsync no longer appends the set_model_response tool. - InstructionsLlmRequestProcessor no longer appends the matching instruction. - BasicLlmRequestProcessor now DOES set the native response schema. The third site is load-bearing: gating only the first two would leave an affected request with neither mechanism, which is worse than the old behaviour. Tests assert both polarities so exactly one mechanism is always active. Only Vertex AI + Gemini 2.0+ + outputSchema + tools changes behaviour; every other combination is unchanged. * Simplify canUseOutputSchemaWithTools to a plain model-name predicate Addresses simplicity-audit findings: - Narrow the signature from `string | BaseLlm` to `string`. adk-python needs the union to isinstance-check LiteLlm; adk-js has no LiteLlm, so the BaseLlm branch only read `.model`, which the three callers now do themselves. This also matches isGemini2OrAbove(modelString: string). - Condense the JSDoc note on Early Access Program names to one sentence. - Drop the two duplicated call-site comments, keeping only the one on the inverse-polarity condition in the basic processor. - Collapse `!agent.tools || agent.tools.length === 0` to `!agent.tools?.length`. The two removed helper tests exercised the BaseLlm overload that no longer exists; the 11-row model-name table is untouched and the helper keeps 100% line and branch coverage. * Tighten output-schema JSDoc and align the two tool-presence checks Second simplicity-audit round: drop the caller-policy paragraph from the helper JSDoc (the surviving call-site comment already names the injection sites, and the "more reliable" rationale stays in the one-line summary), and use `agent.tools?.length` in the instructions processor so both processors spell the same test the same way. --------- Co-authored-by: Amaad Martin <amaadmartin@google.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Link to an existing issue (if applicable):
N/A
Or, if no issue exists, describe the change:
Problem: adk-js can only consume MCP. Everything under
core/src/tools/mcp/(
mcp_toolset.ts,mcp_tool.ts,mcp_session_manager.ts,load_mcp_resource_tool.ts) is client-side, so there is no way to publish an ADKagent as an MCP server. An MCP host (Claude Code, OpenAI Codex, an IDE, any
MCP client) therefore cannot drive an adk-js agent. adk-python has both
directions:
src/google/adk/tools/mcp_tool/_agent_to_mcp.pyexposesto_mcp_server. This PR closes that parity gap.Solution: a new module
core/src/tools/mcp/agent_to_mcp.tsexportingtoMcpServer(agent, options?), which returns a configuredMcpServerthatregisters the whole agent as one MCP tool. The host sends
{request: "..."}and receives the agent's final response; it never imports ADKand never sees the agent's individual tools.
Per tool call the handler resolves the ADK session for the connection (creating
it on the first call), wraps the request as user
Content, drivesRunner.runAsync, forwards intermediate text events as MCP progressnotifications, and maps the final response parts to MCP content blocks
(text ->
TextContent,image/*->ImageContent,audio/*->AudioContent,anything else inline ->
EmbeddedResourceunderresource://adk-agent/inline-data).Shape follows the in-repo A2A precedent
core/src/a2a/agent_to_a2a.ts(toA2a):a free function plus a
To<X>Optionsinterface, arunnerescape hatch,in-memory services by default, named exports from
core/src/index.ts. Theserver is returned unconnected: it opens no socket and binds no port, so the
caller owns the transport and therefore any network exposure and its
authentication.
toA2a'sallowUnauthenticatedgate is deliberately notcopied — it exists because
toA2amounts a network-reachable Express surface bydefault, which is not true of an object that cannot listen on anything. The
Python reference has no such gate either.
No new dependency.
@modelcontextprotocol/sdkis already a runtimedependency of
core(core/package.json:"^1.26.0"; resolved 1.29.0), as iszod("^4.2.1").package.jsonandpackage-lock.jsonare untouched.Deliberate deviations from the Python reference (parity wins for
wire-observable values; local convention wins for in-process details):
|| "adk_agent"name fallback. Python writesapp_name=agent.name or "adk_agent"andtool_name = name or agent.name or "adk_agent". adk-js'sBaseAgentconstructor runsvalidateAgentName(
core/src/agents/base_agent.ts), which rejects an empty or non-identifiername, so the fallback is unreachable in TypeScript. An uncoverable branch is
worse than a missing one, so it is omitted: the code is
options.name ?? agent.name.WeakKeyDictionary. Python'sFastMCPmultiplexes many connections through one server object, so it keysan ADK session per connection on
ctx.session. The TypeScript SDK'sMcpServerowns exactly one transport (server.connect(transport)), so theserver instance is the connection and a
WeakMapwould hold exactly onekey. It is a memoised promise so two concurrent tool calls cannot race into
two sessions. The doc comment tells callers serving several clients (e.g.
streamable HTTP) to build one server per client session, as the MCP SDK
itself recommends.
InMemoryCredentialService. Python's_build_runnerwires one.adk-js has no precedent for it: that would have been the only credential
service instantiation in all of
core/src, nothing incore/srcreadsRunner.credentialServicebeyond forwarding it to the invocation context,and no other entry point supplies one — not
toA2a, notInMemoryRunner,not the CLI. Service wiring never crosses the MCP boundary, so by the parity
rule (wire-observable values follow the reference, in-process details follow
local convention) local convention wins. The default runner is therefore just
new InMemoryRunner({agent, appName: agent.name}), which already bundles theartifact, session and memory services. If ADK later wants default credential
storage, that is a repo-wide decision rather than something this entry point
should introduce alone.
types.Blob.dataisbytes, so_part_to_contentcallsbase64.b64encode(...). In@google/genai,Blob.datais already abase64 string ("The raw bytes of the data. @remarks Encoded as base64
string." —
genai.d.ts). Re-encoding would double-base64 every image andaudio payload. Mutation proof 1 below pins this.
One deviation from the approved plan sketch: the memoised session promise
clears itself when creation fails
(a
.catch((error: unknown) => {sessionIdPromise = undefined; throw error;})on the memoised promise).Without it, a single transient session-store error would be cached and brick
every later call on that server. Covered by
retries session creation after a failed attemptand mutation proof 4.Collision check (required before implementation). All 479 open PRs on the
fork were listed and filtered for MCP/server/agent-to-X work:
The three live MCP PRs are all client-side and touch different files: #147
(
mcp_toolset.ts), #141 (core/src/agents/mcp_instruction_provider.ts), #112(
mcp_session_manager.ts,core/src/utils/error_utils.ts). No open PR touchescore/src/tools/mcp/agent_to_mcp.tsor implementstoMcpServer, and no suchsymbol exists on
main. #141 also appends tocore/src/index.ts; that is anadjacent-line export addition, not a functional overlap, so this branch is cut
from
mainrather than stacked.Not split into a stack: the diff is one logical checkpoint — a single new
module (207 lines) plus its test file (534 lines) and two export lines. Splitting
would mean landing the implementation with no tests.
Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
core/test/tools/mcp/agent_to_mcp_test.ts— 26 tests, all green:The
toMcpServercases drive the registered tool through a realInMemoryTransport.createLinkedPair()+Clientpair, so JSON-RPC framing,input-schema validation,
listTools,getInstructionsand progressnotifications are exercised for real, in-process, with no network and no child
process. Results are validated against the SDK's own
CallToolResultSchema.Runner,InMemorySessionServiceandBaseAgentare real; the only fixture isa
ScriptedAgentthat replays a fixedEvent[], so no runner stub and no castis needed. Error paths covered: a rejecting session service (tool result is
isError, and the next call recovers), an intermediate event with no text, acall with no progress token, events with no content parts, and parts with
nothing renderable.
Coverage of the new module, measured with
--coverage.include:The repo-wide thresholds in
vitest.config.tswere not touched.Mutation proofs (each mutation applied to the implementation alone, the
suite re-run, then reverted; source verified byte-identical afterwards):
const data = blob.data->Buffer.from(blob.data).toString('base64')(re-introduce the double base64 encode)expected 'UE5HLUJZVEVT' to be 'PNG-BYTES'if (!message || progressToken === undefined)->if (!message)(drop the progress-token guard)expected "spy" to not be called at all, but actually been called 1 timessessionIdPromise-> an unconditionalcreateSession(...)on every callexpected "createSession" to be called 1 times, but got 2 times.catchthat clears a failed session promiseexpected true to be falsyif (blob?.data === undefined)->if (!blob?.data)(truthiness instead of presence)expected a resource block, got undefinedif (isFinalResponse(event))->if (true)expected [ …(2) ] to deeply equal [ { type: 'text', text: 'answer' } ]if (!parts?.length) continueskipTypeErroron iterating undefined partsManual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
A throwaway script (intentionally not committed —
tests/e2e/spawns realsubprocesses and this needs no live model) drove the helper over a real stdio
transport across two processes: a child running
toMcpServer(agent).connect(new StdioServerTransport()), and a parentClienton
StdioClientTransport. To reproduce, build (npm run build) and run a serverscript that connects
toMcpServer(agent)to aStdioServerTransport, then driveit with a
StdioClientTransportclient (ornpx @modelcontextprotocol/inspector).Observed output:
i.e. exactly one tool is advertised, the intermediate event arrives as a progress
notification, text and image blocks come back, the image payload round-trips
byte-identically (no double base64), and a second call succeeds on the same
session.
Other local validation on the pushed commit:
npx tsc --noEmitreports pre-existing errors in unrelated test files (the sameones PR #207 is addressing); none are in the files this PR touches.
Note on test imports: interoperating symbols (
BaseAgent,Runner,InMemorySessionService,createEvent) are imported from../../../src/...rather than
@google/adk, matchingcore/test/a2a/agent_to_a2a_test.ts. Mixingthe two is not possible here: this file must import the non-exported helpers
runAgent/partToContentby relative path, and undertscthe packagespecifier resolves to
core/dist/types, so a package-typedRunneris notassignable to the src-typed parameter (
BaseAgent's protected members make thetwo declarations nominally distinct). The first test
(
is exported from the package entry point) still pins the new public export byasserting
@google/adk'stoMcpServeris the same function.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.
CI note: the first
run-tests (windows-latest)attempt failed oncore/test/code_executors/unsafe_local_code_executor_test.ts > should execute shell code and return stdout(Test timed out in 5000ms), which is unrelated tothis change and is a known Windows flake — the identical failure appears on the
unrelated branch
fix/dev-workspace-undeclared-dependencies(run 30828447776).On the re-run,
run-testspassed on ubuntu-latest, windows-latest andmacos-latest.
Complexity review round 1 (addressed in
dab6dee, no argument, codechanged): the hand-rolled
buildRunnerwas replaced with the existing publicInMemoryRunner(see deviation 3 above for the credential-service reasoning),and the single-caller
createSessionIdwrapper was folded into the memoisedpromise. Net −22 lines in the source file (230 -> 207). All 26 tests still pass,
coverage of the module is still 100%/100%/100%/100%, and the two mutation proofs
that touch the rewritten code (3 and 4) were re-run against the new form and
still fail the named tests.