Skip to content

Feat: expose an ADK agent as an MCP server (toMcpServer) - #580

Open
AmaadMartin wants to merge 4 commits into
mainfrom
feat/agent-to-mcp-server
Open

Feat: expose an ADK agent as an MCP server (toMcpServer)#580
AmaadMartin wants to merge 4 commits into
mainfrom
feat/agent-to-mcp-server

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

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

  2. 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 ADK
agent 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.py exposes
to_mcp_server. This PR closes that parity gap.

Solution: a new module core/src/tools/mcp/agent_to_mcp.ts exporting
toMcpServer(agent, options?), which returns a configured McpServer that
registers the whole agent as one MCP tool. The host sends
{request: "..."} and receives the agent's final response; it never imports ADK
and 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, drives
Runner.runAsync, forwards intermediate text events as MCP progress
notifications, and maps the final response parts to MCP content blocks
(text -> TextContent, image/* -> ImageContent, audio/* -> AudioContent,
anything else inline -> EmbeddedResource under
resource://adk-agent/inline-data).

import {LlmAgent, toMcpServer} from '@google/adk';
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';

const server = toMcpServer(diceAgent);
await server.connect(new StdioServerTransport());

Shape follows the in-repo A2A precedent core/src/a2a/agent_to_a2a.ts (toA2a):
a free function plus a To<X>Options interface, a runner escape hatch,
in-memory services by default, named exports from core/src/index.ts. The
server 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's allowUnauthenticated gate is deliberately not
copied — it exists because toA2a mounts a network-reachable Express surface by
default, which is not true of an object that cannot listen on anything. The
Python reference has no such gate either.

No new dependency. @modelcontextprotocol/sdk is already a runtime
dependency of core (core/package.json: "^1.26.0"; resolved 1.29.0), as is
zod ("^4.2.1"). package.json and package-lock.json are untouched.

Deliberate deviations from the Python reference (parity wins for
wire-observable values; local convention wins for in-process details):

  1. No || "adk_agent" name fallback. Python writes
    app_name=agent.name or "adk_agent" and tool_name = name or agent.name or "adk_agent". adk-js's BaseAgent constructor runs validateAgentName
    (core/src/agents/base_agent.ts), which rejects an empty or non-identifier
    name, 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.
  2. One memoised session id instead of a WeakKeyDictionary. Python's
    FastMCP multiplexes many connections through one server object, so it keys
    an ADK session per connection on ctx.session. The TypeScript SDK's
    McpServer owns exactly one transport (server.connect(transport)), so the
    server instance is the connection and a WeakMap would hold exactly one
    key. 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.
  3. No InMemoryCredentialService. Python's _build_runner wires one.
    adk-js has no precedent for it: that would have been the only credential
    service instantiation in all of core/src, nothing in core/src reads
    Runner.credentialService beyond forwarding it to the invocation context,
    and no other entry point supplies one — not toA2a, not InMemoryRunner,
    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 the
    artifact, 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.
  4. Inline data is passed through verbatim, not re-encoded. Python's
    types.Blob.data is bytes, so _part_to_content calls
    base64.b64encode(...). In @google/genai, Blob.data is already a
    base64 string
    ("The raw bytes of the data. @remarks Encoded as base64
    string." — genai.d.ts). Re-encoding would double-base64 every image and
    audio 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 attempt and 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:

gh pr list --repo AmaadMartin/adk-js --state open --limit 1000 \
  --json number,title,headRefName
gh pr diff <n> --repo AmaadMartin/adk-js --name-only   # 147, 141, 112

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 touches
core/src/tools/mcp/agent_to_mcp.ts or implements toMcpServer, and no such
symbol exists on main. #141 also appends to core/src/index.ts; that is an
adjacent-line export addition, not a functional overlap, so this branch is cut
from main rather 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:

npx vitest run --project unit:core core/test/tools/mcp/agent_to_mcp_test.ts
  Test Files  1 passed (1)
       Tests  26 passed (26)

The toMcpServer cases drive the registered tool through a real
InMemoryTransport.createLinkedPair() + Client pair, so JSON-RPC framing,
input-schema validation, listTools, getInstructions and progress
notifications are exercised for real, in-process, with no network and no child
process. Results are validated against the SDK's own CallToolResultSchema.
Runner, InMemorySessionService and BaseAgent are real; the only fixture is
a ScriptedAgent that replays a fixed Event[], so no runner stub and no cast
is needed. Error paths covered: a rejecting session service (tool result is
isError, and the next call recovers), an intermediate event with no text, a
call with no progress token, events with no content parts, and parts with
nothing renderable.

Coverage of the new module, measured with --coverage.include:

File             | % Stmts | % Branch | % Funcs | % Lines
 agent_to_mcp.ts |     100 |      100 |     100 |     100

The repo-wide thresholds in vitest.config.ts were not touched.

Mutation proofs (each mutation applied to the implementation alone, the
suite re-run, then reverted; source verified byte-identical afterwards):

# Mutation Failing test(s) Failure message
1 const data = blob.data -> Buffer.from(blob.data).toString('base64') (re-introduce the double base64 encode) maps image output ... without re-encoding it; maps audio output; maps other inline data; defaults the mime type expected 'UE5HLUJZVEVT' to be 'PNG-BYTES'
2 if (!message || progressToken === undefined) -> if (!message) (drop the progress-token guard) sends no progress when the host supplied no progress token expected "spy" to not be called at all, but actually been called 1 times
3 memoised sessionIdPromise -> an unconditional createSession(...) on every call reuses one session across calls on one connection; creates the session once when calls overlap expected "createSession" to be called 1 times, but got 2 times
4 drop the .catch that clears a failed session promise retries session creation after a failed attempt expected true to be falsy
5 if (blob?.data === undefined) -> if (!blob?.data) (truthiness instead of presence) keeps an empty inline payload expected a resource block, got undefined
6 if (isFinalResponse(event)) -> if (true) returns only the final response content; reports intermediate events as progress; drops intermediate events when no tool call context is supplied; delivers intermediate events as progress notifications expected [ …(2) ] to deeply equal [ { type: 'text', text: 'answer' } ]
7 remove the if (!parts?.length) continue skip skips events that carry no content parts TypeError on iterating undefined parts

Manual 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 real
subprocesses 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 parent Client
on StdioClientTransport. To reproduce, build (npm run build) and run a server
script that connects toMcpServer(agent) to a StdioServerTransport, then drive
it with a StdioClientTransport client (or npx @modelcontextprotocol/inspector).
Observed output:

TOOLS: [{"name":"dice_agent","description":"Rolls dice."}]
PROGRESS: ["thinking about it"]
RESULT: [{"type":"text","text":"rolled a 4"},{"type":"image","data":"UE5HLUJZVEVT","mimeType":"image/png"}]
IMAGE ROUNDTRIP: PNG-BYTES
SECOND CALL OK: true

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 --noEmit          # no errors in the changed files
npm run lint              # clean
npm run format:check      # clean
npm run docs:check        # clean (typedoc, warnings as errors)
npm run build             # clean

npx tsc --noEmit reports pre-existing errors in unrelated test files (the same
ones 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, matching core/test/a2a/agent_to_a2a_test.ts. Mixing
the two is not possible here: this file must import the non-exported helpers
runAgent/partToContent by relative path, and under tsc the package
specifier resolves to core/dist/types, so a package-typed Runner is not
assignable to the src-typed parameter (BaseAgent's protected members make the
two declarations nominally distinct). The first test
(is exported from the package entry point) still pins the new public export by
asserting @google/adk's toMcpServer is 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 on
core/test/code_executors/unsafe_local_code_executor_test.ts > should execute shell code and return stdout (Test timed out in 5000ms), which is unrelated to
this 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-tests passed on ubuntu-latest, windows-latest and
macos-latest.

Complexity review round 1 (addressed in dab6dee, no argument, code
changed): the hand-rolled buildRunner was replaced with the existing public
InMemoryRunner (see deviation 3 above for the credential-service reasoning),
and the single-caller createSessionId wrapper was folded into the memoised
promise. 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.

Amaad Martin 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>
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.

1 participant