|
| 1 | +# Copyright 2026 Google LLC |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Expose an ADK agent as an MCP server.""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import base64 |
| 20 | +from typing import MutableMapping |
| 21 | +from typing import Optional |
| 22 | +import weakref |
| 23 | + |
| 24 | +from google.genai import types |
| 25 | +from mcp import types as mcp_types |
| 26 | +from mcp.server.fastmcp import Context |
| 27 | +from mcp.server.fastmcp import FastMCP |
| 28 | + |
| 29 | +from ...agents.base_agent import BaseAgent |
| 30 | +from ...artifacts.in_memory_artifact_service import InMemoryArtifactService |
| 31 | +from ...auth.credential_service.in_memory_credential_service import InMemoryCredentialService |
| 32 | +from ...features import experimental |
| 33 | +from ...features import FeatureName |
| 34 | +from ...memory.in_memory_memory_service import InMemoryMemoryService |
| 35 | +from ...runners import Runner |
| 36 | +from ...sessions.in_memory_session_service import InMemorySessionService |
| 37 | + |
| 38 | +_MCP_USER_ID = "mcp_user" |
| 39 | +_INLINE_RESOURCE_URI = "resource://adk-agent/inline-data" |
| 40 | + |
| 41 | + |
| 42 | +def _build_runner(agent: BaseAgent) -> Runner: |
| 43 | + """Builds a Runner for the agent using in-memory services.""" |
| 44 | + return Runner( |
| 45 | + app_name=agent.name or "adk_agent", |
| 46 | + agent=agent, |
| 47 | + artifact_service=InMemoryArtifactService(), |
| 48 | + session_service=InMemorySessionService(), |
| 49 | + memory_service=InMemoryMemoryService(), |
| 50 | + credential_service=InMemoryCredentialService(), |
| 51 | + ) |
| 52 | + |
| 53 | + |
| 54 | +def _part_to_content(part: types.Part) -> Optional[mcp_types.ContentBlock]: |
| 55 | + """Maps one ADK content part to an MCP content block. |
| 56 | +
|
| 57 | + Args: |
| 58 | + part: An ADK content part from the agent's response. |
| 59 | +
|
| 60 | + Returns: |
| 61 | + The matching MCP content block (text, image, audio, or embedded resource), |
| 62 | + or None for a part with no renderable content (e.g. a function call). |
| 63 | + """ |
| 64 | + if part.text: |
| 65 | + return mcp_types.TextContent(type="text", text=part.text) |
| 66 | + blob = part.inline_data |
| 67 | + if blob is not None and blob.data is not None: |
| 68 | + data = base64.b64encode(blob.data).decode("ascii") |
| 69 | + mime = blob.mime_type or "application/octet-stream" |
| 70 | + if mime.startswith("image/"): |
| 71 | + return mcp_types.ImageContent(type="image", data=data, mimeType=mime) |
| 72 | + if mime.startswith("audio/"): |
| 73 | + return mcp_types.AudioContent(type="audio", data=data, mimeType=mime) |
| 74 | + return mcp_types.EmbeddedResource( |
| 75 | + type="resource", |
| 76 | + resource=mcp_types.BlobResourceContents( |
| 77 | + uri=_INLINE_RESOURCE_URI, blob=data, mimeType=mime |
| 78 | + ), |
| 79 | + ) |
| 80 | + return None |
| 81 | + |
| 82 | + |
| 83 | +async def _run_agent( |
| 84 | + runner: Runner, |
| 85 | + request: str, |
| 86 | + ctx: Optional[Context] = None, |
| 87 | + sessions: Optional[MutableMapping[object, str]] = None, |
| 88 | +) -> list[mcp_types.ContentBlock]: |
| 89 | + """Runs the agent for one request and returns its final response content. |
| 90 | +
|
| 91 | + When ``ctx`` and ``sessions`` are supplied, one ADK session is reused per MCP |
| 92 | + connection, so successive calls form a single conversation; otherwise a fresh |
| 93 | + session is created. Intermediate (non-final) text events are forwarded as MCP |
| 94 | + progress notifications when ``ctx`` is supplied; progress is a no-op unless |
| 95 | + the host requested it. |
| 96 | +
|
| 97 | + Args: |
| 98 | + runner: The Runner that executes the agent. |
| 99 | + request: The user request text for this call. |
| 100 | + ctx: The MCP tool call context, used for progress and session reuse. |
| 101 | + sessions: Per-connection map from MCP connection to ADK session id. |
| 102 | +
|
| 103 | + Returns: |
| 104 | + The agent's final response as a list of MCP content blocks (text plus any |
| 105 | + images, audio, or other data the agent produced). |
| 106 | + """ |
| 107 | + session_id: Optional[str] = None |
| 108 | + if ctx is not None and sessions is not None: |
| 109 | + session_id = sessions.get(ctx.session) |
| 110 | + if session_id is None: |
| 111 | + session = await runner.session_service.create_session( |
| 112 | + app_name=runner.app_name, user_id=_MCP_USER_ID |
| 113 | + ) |
| 114 | + session_id = session.id |
| 115 | + if ctx is not None and sessions is not None: |
| 116 | + sessions[ctx.session] = session_id |
| 117 | + new_message = types.Content(role="user", parts=[types.Part(text=request)]) |
| 118 | + final_content: list[mcp_types.ContentBlock] = [] |
| 119 | + async for event in runner.run_async( |
| 120 | + user_id=_MCP_USER_ID, |
| 121 | + session_id=session_id, |
| 122 | + new_message=new_message, |
| 123 | + ): |
| 124 | + if not (event.content and event.content.parts): |
| 125 | + continue |
| 126 | + if event.is_final_response(): |
| 127 | + for part in event.content.parts: |
| 128 | + block = _part_to_content(part) |
| 129 | + if block is not None: |
| 130 | + final_content.append(block) |
| 131 | + elif ctx is not None: |
| 132 | + text = "".join(part.text or "" for part in event.content.parts) |
| 133 | + if text: |
| 134 | + await ctx.report_progress(progress=0.0, message=text) |
| 135 | + return final_content |
| 136 | + |
| 137 | + |
| 138 | +@experimental(FeatureName.MCP_AGENT_SERVER) |
| 139 | +def to_mcp_server( |
| 140 | + agent: BaseAgent, |
| 141 | + *, |
| 142 | + name: Optional[str] = None, |
| 143 | + instructions: Optional[str] = None, |
| 144 | + runner: Optional[Runner] = None, |
| 145 | +) -> FastMCP: |
| 146 | + """Exposes an ADK agent as an MCP server. |
| 147 | +
|
| 148 | + The returned server registers a single MCP tool that runs the agent: an MCP |
| 149 | + host (e.g. Claude Code, OpenAI Codex, an IDE, or any MCP client) sends a |
| 150 | + request string and receives the agent's final response, including any images |
| 151 | + or audio the agent produced. This is the MCP counterpart of ``to_a2a``; it |
| 152 | + lets harnesses that speak MCP drive an ADK agent. |
| 153 | +
|
| 154 | + One ADK session is kept per MCP connection, so successive tool calls on the |
| 155 | + same connection form a single multi-turn conversation. |
| 156 | +
|
| 157 | + The caller chooses the transport, e.g. ``server.run(transport="stdio")`` for |
| 158 | + a local host or ``server.run(transport="streamable-http")`` for a networked |
| 159 | + one. |
| 160 | +
|
| 161 | + Args: |
| 162 | + agent: The ADK agent to serve. |
| 163 | + name: The MCP server and tool name. Defaults to the agent's name. |
| 164 | + instructions: Optional instructions the MCP host may show to its model. |
| 165 | + runner: A pre-built Runner. If omitted, one is created with in-memory |
| 166 | + services. |
| 167 | +
|
| 168 | + Returns: |
| 169 | + A ``FastMCP`` server exposing the agent as a single tool. |
| 170 | +
|
| 171 | + Example:: |
| 172 | +
|
| 173 | + agent = LlmAgent(name="assistant", model="gemini-2.0-flash", ...) |
| 174 | + server = to_mcp_server(agent) |
| 175 | + server.run(transport="stdio") |
| 176 | + """ |
| 177 | + tool_name = name or agent.name or "adk_agent" |
| 178 | + server = FastMCP(name=tool_name, instructions=instructions) |
| 179 | + agent_runner = runner if runner is not None else _build_runner(agent) |
| 180 | + # Maps each MCP connection to its ADK session; WeakKeyDictionary drops the |
| 181 | + # entry when the connection is garbage-collected. pylint wrongly flags the |
| 182 | + # WeakKeyDictionary() instantiation below as abstract-class-instantiated. |
| 183 | + # pylint: disable-next=abstract-class-instantiated |
| 184 | + sessions: MutableMapping[object, str] = weakref.WeakKeyDictionary() |
| 185 | + |
| 186 | + async def call_agent( |
| 187 | + request: str, ctx: Context |
| 188 | + ) -> list[mcp_types.ContentBlock]: |
| 189 | + return await _run_agent(agent_runner, request, ctx, sessions) |
| 190 | + |
| 191 | + server.add_tool( |
| 192 | + call_agent, |
| 193 | + name=tool_name, |
| 194 | + description=agent.description or f"Run the {tool_name} agent.", |
| 195 | + structured_output=False, |
| 196 | + ) |
| 197 | + return server |
0 commit comments