diff --git a/core/src/index.ts b/core/src/index.ts index 242f18fca..bb61af3b6 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -59,6 +59,8 @@ export {RunSkillScriptTool} from './tools/skill/run_skill_script_tool.js'; export * from './integrations/agent_registry/agent_registry.js'; export * from './telemetry/google_cloud.js'; export * from './telemetry/setup.js'; +export {toMcpServer} from './tools/mcp/agent_to_mcp.js'; +export type {ToMcpServerOptions} from './tools/mcp/agent_to_mcp.js'; export * from './tools/mcp/load_mcp_resource_tool.js'; export * from './tools/mcp/mcp_session_manager.js'; export * from './tools/mcp/mcp_tool.js'; diff --git a/core/src/tools/mcp/agent_to_mcp.ts b/core/src/tools/mcp/agent_to_mcp.ts new file mode 100644 index 000000000..87bf18301 --- /dev/null +++ b/core/src/tools/mcp/agent_to_mcp.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {createUserContent, Part} from '@google/genai'; +import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {RequestHandlerExtra} from '@modelcontextprotocol/sdk/shared/protocol.js'; +import { + ContentBlock, + ServerNotification, + ServerRequest, +} from '@modelcontextprotocol/sdk/types.js'; +import {z} from 'zod'; + +import {BaseAgent} from '../../agents/base_agent.js'; +import {isFinalResponse} from '../../events/event.js'; +import {InMemoryRunner} from '../../runner/in_memory_runner.js'; +import {Runner} from '../../runner/runner.js'; +import {version} from '../../version.js'; + +/** The synthetic ADK user id used for every MCP-driven conversation. */ +const MCP_USER_ID = 'mcp_user'; + +/** The URI carried by inline data that is neither an image nor audio. */ +const INLINE_RESOURCE_URI = 'resource://adk-agent/inline-data'; + +/** The default MIME type for inline data that does not declare one. */ +const DEFAULT_MIME_TYPE = 'application/octet-stream'; + +/** The context object the MCP SDK passes to a tool callback. */ +type ToolCallExtra = RequestHandlerExtra; + +/** Options for {@link toMcpServer}. */ +export interface ToMcpServerOptions { + /** The MCP server and tool name. Defaults to the agent's name. */ + name?: string; + /** Optional instructions the MCP host may show to its model. */ + instructions?: string; + /** A pre-built Runner. If omitted, one is built with in-memory services. */ + runner?: Runner; +} + +/** + * Maps one ADK content part to an MCP content block. + * + * @param part An ADK content part from the agent's response. + * @returns The matching MCP content block (text, image, audio, or embedded + * resource), or `undefined` for a part with no renderable content (e.g. a + * function call). + */ +export function partToContent(part: Part): ContentBlock | undefined { + if (part.text) { + return {type: 'text', text: part.text}; + } + const blob = part.inlineData; + if (blob?.data === undefined) { + return undefined; + } + // `@google/genai` already types `Blob.data` as a base64 string, so it is + // forwarded verbatim; re-encoding it would double-base64 every payload. + const data = blob.data; + const mimeType = blob.mimeType || DEFAULT_MIME_TYPE; + switch (mimeType.split('/')[0]) { + case 'image': + return {type: 'image', data, mimeType}; + case 'audio': + return {type: 'audio', data, mimeType}; + default: + return { + type: 'resource', + resource: {uri: INLINE_RESOURCE_URI, blob: data, mimeType}, + }; + } +} + +/** + * Forwards an intermediate agent message to the MCP host as progress. + * + * Progress is best effort: a host that did not supply a progress token did not + * ask for progress, and notifying it anyway would violate the MCP protocol. + */ +async function reportProgress( + extra: ToolCallExtra, + message: string, +): Promise { + const progressToken = extra._meta?.progressToken; + if (!message || progressToken === undefined) { + return; + } + await extra.sendNotification({ + method: 'notifications/progress', + params: {progressToken, progress: 0, message}, + }); +} + +/** + * Runs the agent for one request and returns its final response content. + * + * Intermediate (non-final) text events are forwarded as MCP progress + * notifications when `extra` is supplied. + * + * @param runner The Runner that executes the agent. + * @param request The user request text for this call. + * @param sessionId The ADK session this call belongs to. + * @param extra The MCP tool call context, used to report progress. + * @returns The agent's final response as a list of MCP content blocks (text + * plus any images, audio, or other data the agent produced). + */ +export async function runAgent( + runner: Runner, + request: string, + sessionId: string, + extra?: ToolCallExtra, +): Promise { + const finalContent: ContentBlock[] = []; + for await (const event of runner.runAsync({ + userId: MCP_USER_ID, + sessionId, + newMessage: createUserContent(request), + })) { + const parts = event.content?.parts; + if (!parts?.length) { + continue; + } + if (isFinalResponse(event)) { + for (const part of parts) { + const block = partToContent(part); + if (block !== undefined) { + finalContent.push(block); + } + } + } else if (extra !== undefined) { + await reportProgress( + extra, + parts.map((part) => part.text ?? '').join(''), + ); + } + } + return finalContent; +} + +/** + * Exposes an ADK agent as an MCP server. + * + * The returned server registers a single MCP tool that runs the agent: an MCP + * host (e.g. Claude Code, OpenAI Codex, an IDE, or any MCP client) sends a + * request string and receives the agent's final response, including any images + * or audio the agent produced. This is the MCP counterpart of `toA2a`; it lets + * harnesses that speak MCP drive an ADK agent. + * + * All tool calls on the returned server share one ADK session, so successive + * calls form a single multi-turn conversation. An `McpServer` owns exactly one + * transport, so a host that serves several clients — for example over + * streamable HTTP — should build one server per client session. + * + * The server is returned unconnected and binds nothing: the caller chooses the + * transport, and therefore owns any network exposure and its authentication. + * + * @param agent The ADK agent to serve. + * @param options Configuration options. + * @returns An `McpServer` exposing the agent as a single tool, ready for + * `server.connect(transport)`. + * @experimental (Experimental, subject to change) + * + * @example + * ```typescript + * const agent = new LlmAgent({name: 'assistant', model: 'gemini-2.0-flash'}); + * const server = toMcpServer(agent); + * await server.connect(new StdioServerTransport()); + * ``` + */ +export function toMcpServer( + agent: BaseAgent, + options: ToMcpServerOptions = {}, +): McpServer { + const toolName = options.name ?? agent.name; + const server = new McpServer( + {name: toolName, version}, + {instructions: options.instructions}, + ); + const agentRunner = + options.runner ?? new InMemoryRunner({agent, appName: agent.name}); + let sessionIdPromise: Promise | undefined; + + server.registerTool( + toolName, + { + description: agent.description || `Run the ${toolName} agent.`, + inputSchema: {request: z.string().describe('The request for the agent.')}, + }, + async ({request}, extra) => { + // A failed creation must not stay memoised, or one transient session + // store error would brick every later call on this server. + sessionIdPromise ??= agentRunner.sessionService + .createSession({appName: agentRunner.appName, userId: MCP_USER_ID}) + .then((session) => session.id) + .catch((error: unknown) => { + sessionIdPromise = undefined; + throw error; + }); + const sessionId = await sessionIdPromise; + return {content: await runAgent(agentRunner, request, sessionId, extra)}; + }, + ); + return server; +} diff --git a/core/test/tools/mcp/agent_to_mcp_test.ts b/core/test/tools/mcp/agent_to_mcp_test.ts new file mode 100644 index 000000000..72188a33a --- /dev/null +++ b/core/test/tools/mcp/agent_to_mcp_test.ts @@ -0,0 +1,534 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {toMcpServer as publicToMcpServer} from '@google/adk'; +import {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js'; +import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {RequestHandlerExtra} from '@modelcontextprotocol/sdk/shared/protocol.js'; +import { + AudioContent, + CallToolResult, + CallToolResultSchema, + ContentBlock, + EmbeddedResource, + ImageContent, + ServerNotification, + ServerRequest, + TextContent, +} from '@modelcontextprotocol/sdk/types.js'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {BaseAgent} from '../../../src/agents/base_agent.js'; +import {InvocationContext} from '../../../src/agents/invocation_context.js'; +import {createEvent, Event} from '../../../src/events/event.js'; +import {Runner} from '../../../src/runner/runner.js'; +import {InMemorySessionService} from '../../../src/sessions/in_memory_session_service.js'; +import { + partToContent, + runAgent, + toMcpServer, +} from '../../../src/tools/mcp/agent_to_mcp.js'; + +/** The wire-observable user id every MCP-driven conversation runs under. */ +const MCP_USER_ID = 'mcp_user'; + +/** The wire-observable URI for inline data that is neither image nor audio. */ +const INLINE_RESOURCE_URI = 'resource://adk-agent/inline-data'; + +const APP_NAME = 'agent_to_mcp_app'; +const AGENT_NAME = 'my_agent'; + +/** An agent that replays a fixed event script. */ +class ScriptedAgent extends BaseAgent { + private readonly events: Event[]; + + constructor(config: {name: string; description?: string; events: Event[]}) { + super({name: config.name, description: config.description}); + this.events = config.events; + } + + protected async *runAsyncImpl( + _context: InvocationContext, + ): AsyncGenerator { + for (const event of this.events) { + yield event; + } + } + + protected async *runLiveImpl( + _context: InvocationContext, + ): AsyncGenerator {} +} + +function textEvent(text: string, options: {partial?: boolean} = {}): Event { + return createEvent({ + author: AGENT_NAME, + partial: options.partial, + content: {role: 'model', parts: [{text}]}, + }); +} + +function inlineDataEvent(data: string, mimeType?: string): Event { + return createEvent({ + author: AGENT_NAME, + content: {role: 'model', parts: [{inlineData: {data, mimeType}}]}, + }); +} + +function createRunner(events: Event[], appName = APP_NAME): Runner { + return new Runner({ + appName, + agent: new ScriptedAgent({name: AGENT_NAME, events}), + sessionService: new InMemorySessionService(), + }); +} + +async function startSession(runner: Runner): Promise { + const session = await runner.sessionService.createSession({ + appName: runner.appName, + userId: MCP_USER_ID, + }); + return session.id; +} + +function createToolCallExtra(options: { + sendNotification: (notification: ServerNotification) => Promise; + progressToken?: string | number; +}): RequestHandlerExtra { + return { + signal: new AbortController().signal, + requestId: 1, + _meta: + options.progressToken === undefined + ? undefined + : {progressToken: options.progressToken}, + sendNotification: options.sendNotification, + sendRequest: () => Promise.reject(new Error('sendRequest is unused')), + }; +} + +const openPairs: Array<{client: Client; server: McpServer}> = []; + +/** Connects a client to the server over a linked in-process transport pair. */ +async function connect(server: McpServer): Promise { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({name: 'test_client', version: '1.0.0'}); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + openPairs.push({client, server}); + return client; +} + +/** Validates a tool result against the MCP schema and returns it typed. */ +function toolResult(result: unknown): CallToolResult { + return CallToolResultSchema.parse(result); +} + +function expectTextBlock(block: ContentBlock | undefined): TextContent { + if (block?.type !== 'text') { + expect.fail(`expected a text block, got ${JSON.stringify(block)}`); + } + return block; +} + +function expectImageBlock(block: ContentBlock | undefined): ImageContent { + if (block?.type !== 'image') { + expect.fail(`expected an image block, got ${JSON.stringify(block)}`); + } + return block; +} + +function expectAudioBlock(block: ContentBlock | undefined): AudioContent { + if (block?.type !== 'audio') { + expect.fail(`expected an audio block, got ${JSON.stringify(block)}`); + } + return block; +} + +function expectResourceBlock( + block: ContentBlock | undefined, +): EmbeddedResource { + if (block?.type !== 'resource') { + expect.fail(`expected a resource block, got ${JSON.stringify(block)}`); + } + return block; +} + +afterEach(async () => { + for (const {client, server} of openPairs.splice(0)) { + await client.close(); + await server.close(); + } +}); + +describe('toMcpServer', () => { + it('is exported from the package entry point', () => { + expect(publicToMcpServer).toBe(toMcpServer); + }); + + it('registers the agent as a single tool', async () => { + const agent = new ScriptedAgent({ + name: AGENT_NAME, + description: 'does useful things', + events: [], + }); + + const client = await connect(toMcpServer(agent)); + const {tools} = await client.listTools(); + + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe(AGENT_NAME); + expect(tools[0].description).toBe('does useful things'); + expect(tools[0].inputSchema.properties).toHaveProperty('request'); + }); + + it('uses the name from the options over the agent name', async () => { + const agent = new ScriptedAgent({name: AGENT_NAME, events: []}); + + const client = await connect(toMcpServer(agent, {name: 'custom'})); + const {tools} = await client.listTools(); + + expect(tools[0].name).toBe('custom'); + }); + + it('falls back to a generated description when the agent has none', async () => { + const agent = new ScriptedAgent({name: AGENT_NAME, events: []}); + + const client = await connect(toMcpServer(agent)); + const {tools} = await client.listTools(); + + expect(tools[0].description).toBe('Run the my_agent agent.'); + }); + + it('passes the instructions to the MCP server', async () => { + const agent = new ScriptedAgent({name: AGENT_NAME, events: []}); + + const client = await connect( + toMcpServer(agent, {instructions: 'Ask the agent anything.'}), + ); + + expect(client.getInstructions()).toBe('Ask the agent anything.'); + }); + + it('runs the agent end to end when the tool is called', async () => { + const agent = new ScriptedAgent({ + name: AGENT_NAME, + events: [textEvent('hello from the agent')], + }); + + const client = await connect(toMcpServer(agent)); + const result = toolResult( + await client.callTool({name: AGENT_NAME, arguments: {request: 'hi'}}), + ); + + expect(result.isError).toBeFalsy(); + expect(expectTextBlock(result.content[0]).text).toBe( + 'hello from the agent', + ); + }); + + it('builds an in-memory runner when none is supplied', async () => { + const agent = new ScriptedAgent({ + name: AGENT_NAME, + events: [textEvent('default services work')], + }); + + const client = await connect(toMcpServer(agent)); + const result = toolResult( + await client.callTool({name: AGENT_NAME, arguments: {request: 'hi'}}), + ); + + expect(result.isError).toBeFalsy(); + expect(expectTextBlock(result.content[0]).text).toBe( + 'default services work', + ); + }); + + it('runs the agent on the supplied runner', async () => { + const runner = createRunner([textEvent('ok')], 'byo_runner_app'); + const createSession = vi.spyOn(runner.sessionService, 'createSession'); + + const client = await connect(toMcpServer(runner.agent, {runner})); + await client.callTool({name: AGENT_NAME, arguments: {request: 'hi'}}); + + expect(createSession).toHaveBeenCalledTimes(1); + expect(createSession).toHaveBeenCalledWith({ + appName: 'byo_runner_app', + userId: MCP_USER_ID, + }); + }); + + it('reuses one session across calls on one connection', async () => { + const runner = createRunner([textEvent('ok')]); + const createSession = vi.spyOn(runner.sessionService, 'createSession'); + const runAsync = vi.spyOn(runner, 'runAsync'); + + const client = await connect(toMcpServer(runner.agent, {runner})); + await client.callTool({name: AGENT_NAME, arguments: {request: 'first'}}); + await client.callTool({name: AGENT_NAME, arguments: {request: 'second'}}); + + expect(createSession).toHaveBeenCalledTimes(1); + const [first, second] = runAsync.mock.calls; + expect(first[0].sessionId).toBe(second[0].sessionId); + }); + + it('creates the session once when calls overlap', async () => { + const runner = createRunner([textEvent('ok')]); + const createSession = vi.spyOn(runner.sessionService, 'createSession'); + + const client = await connect(toMcpServer(runner.agent, {runner})); + await Promise.all([ + client.callTool({name: AGENT_NAME, arguments: {request: 'first'}}), + client.callTool({name: AGENT_NAME, arguments: {request: 'second'}}), + ]); + + expect(createSession).toHaveBeenCalledTimes(1); + }); + + it('uses a separate session for each server', async () => { + const runner = createRunner([textEvent('ok')]); + const createSession = vi.spyOn(runner.sessionService, 'createSession'); + const runAsync = vi.spyOn(runner, 'runAsync'); + + const firstClient = await connect(toMcpServer(runner.agent, {runner})); + const secondClient = await connect(toMcpServer(runner.agent, {runner})); + await firstClient.callTool({name: AGENT_NAME, arguments: {request: 'a'}}); + await secondClient.callTool({name: AGENT_NAME, arguments: {request: 'b'}}); + + expect(createSession).toHaveBeenCalledTimes(2); + const [first, second] = runAsync.mock.calls; + expect(first[0].sessionId).not.toBe(second[0].sessionId); + }); + + it('retries session creation after a failed attempt', async () => { + const runner = createRunner([textEvent('ok')]); + const createSession = vi + .spyOn(runner.sessionService, 'createSession') + .mockRejectedValueOnce(new Error('session store unavailable')); + + const client = await connect(toMcpServer(runner.agent, {runner})); + const failed = toolResult( + await client.callTool({name: AGENT_NAME, arguments: {request: 'first'}}), + ); + const recovered = toolResult( + await client.callTool({name: AGENT_NAME, arguments: {request: 'second'}}), + ); + + expect(failed.isError).toBe(true); + expect(expectTextBlock(failed.content[0]).text).toContain( + 'session store unavailable', + ); + expect(recovered.isError).toBeFalsy(); + expect(expectTextBlock(recovered.content[0]).text).toBe('ok'); + expect(createSession).toHaveBeenCalledTimes(2); + }); + + it('delivers intermediate events to the host as progress notifications', async () => { + const runner = createRunner([ + textEvent('thinking', {partial: true}), + textEvent('done'), + ]); + const reported: Array = []; + + const client = await connect(toMcpServer(runner.agent, {runner})); + const result = toolResult( + await client.callTool( + {name: AGENT_NAME, arguments: {request: 'hi'}}, + undefined, + {onprogress: (progress) => reported.push(progress.message)}, + ), + ); + + expect(reported).toEqual(['thinking']); + expect(expectTextBlock(result.content[0]).text).toBe('done'); + }); +}); + +describe('runAgent', () => { + it('returns only the final response content', async () => { + const runner = createRunner([ + textEvent('thinking', {partial: true}), + textEvent('answer'), + ]); + + const content = await runAgent(runner, 'hi', await startSession(runner)); + + expect(content).toEqual([{type: 'text', text: 'answer'}]); + }); + + it('reports intermediate events as progress', async () => { + const runner = createRunner([ + textEvent('thinking', {partial: true}), + textEvent('done'), + ]); + const sendNotification = vi.fn(async () => {}); + + const content = await runAgent( + runner, + 'hi', + await startSession(runner), + createToolCallExtra({sendNotification, progressToken: 'token-1'}), + ); + + expect(sendNotification).toHaveBeenCalledTimes(1); + expect(sendNotification).toHaveBeenCalledWith({ + method: 'notifications/progress', + params: {progressToken: 'token-1', progress: 0, message: 'thinking'}, + }); + expect(expectTextBlock(content[0]).text).toBe('done'); + }); + + it('sends no progress when the host supplied no progress token', async () => { + const runner = createRunner([ + textEvent('thinking', {partial: true}), + textEvent('done'), + ]); + const sendNotification = vi.fn(async () => {}); + + await runAgent( + runner, + 'hi', + await startSession(runner), + createToolCallExtra({sendNotification}), + ); + + expect(sendNotification).not.toHaveBeenCalled(); + }); + + it('sends no progress for an intermediate event with no text', async () => { + const runner = createRunner([ + createEvent({ + author: AGENT_NAME, + partial: true, + content: {role: 'model', parts: [{thought: true}]}, + }), + textEvent('done'), + ]); + const sendNotification = vi.fn(async () => {}); + + await runAgent( + runner, + 'hi', + await startSession(runner), + createToolCallExtra({sendNotification, progressToken: 7}), + ); + + expect(sendNotification).not.toHaveBeenCalled(); + }); + + it('drops intermediate events when no tool call context is supplied', async () => { + const runner = createRunner([ + textEvent('thinking', {partial: true}), + textEvent('done'), + ]); + + const content = await runAgent(runner, 'hi', await startSession(runner)); + + expect(content).toEqual([{type: 'text', text: 'done'}]); + }); + + it('skips events that carry no content parts', async () => { + const runner = createRunner([ + createEvent({author: AGENT_NAME}), + createEvent({author: AGENT_NAME, content: {role: 'model', parts: []}}), + textEvent('answer'), + ]); + + const content = await runAgent(runner, 'hi', await startSession(runner)); + + expect(content).toEqual([{type: 'text', text: 'answer'}]); + }); + + it('maps image output to an image block without re-encoding it', async () => { + const original = 'PNG-BYTES'; + const data = Buffer.from(original).toString('base64'); + const runner = createRunner([inlineDataEvent(data, 'image/png')]); + + const content = await runAgent(runner, 'draw', await startSession(runner)); + + const block = expectImageBlock(content[0]); + expect(block.mimeType).toBe('image/png'); + expect(Buffer.from(block.data, 'base64').toString()).toBe(original); + }); + + it('maps audio output to an audio block', async () => { + const data = Buffer.from('MP3-BYTES').toString('base64'); + const runner = createRunner([inlineDataEvent(data, 'audio/mpeg')]); + + const content = await runAgent(runner, 'speak', await startSession(runner)); + + const block = expectAudioBlock(content[0]); + expect(block.mimeType).toBe('audio/mpeg'); + expect(block.data).toBe(data); + }); + + it('maps other inline data to an embedded resource', async () => { + const data = Buffer.from('%PDF-1.7').toString('base64'); + const runner = createRunner([inlineDataEvent(data, 'application/pdf')]); + + const content = await runAgent( + runner, + 'report', + await startSession(runner), + ); + + expect(expectResourceBlock(content[0]).resource).toEqual({ + uri: INLINE_RESOURCE_URI, + blob: data, + mimeType: 'application/pdf', + }); + }); + + it('returns an empty array when the agent emits nothing renderable', async () => { + const runner = createRunner([ + createEvent({ + author: AGENT_NAME, + content: { + role: 'model', + parts: [ + {fileData: {fileUri: 'gs://bucket/report.pdf'}}, + {inlineData: {mimeType: 'image/png'}}, + ], + }, + }), + ]); + + const content = await runAgent(runner, 'hi', await startSession(runner)); + + expect(content).toEqual([]); + }); +}); + +describe('partToContent', () => { + it('defaults the mime type when the inline data declares none', () => { + const block = partToContent({inlineData: {data: 'AAAA'}}); + + expect(expectResourceBlock(block).resource).toEqual({ + uri: INLINE_RESOURCE_URI, + blob: 'AAAA', + mimeType: 'application/octet-stream', + }); + }); + + it('keeps an empty inline payload', () => { + const block = partToContent({inlineData: {data: '', mimeType: 'text/csv'}}); + + expect(expectResourceBlock(block).resource).toEqual({ + uri: INLINE_RESOURCE_URI, + blob: '', + mimeType: 'text/csv', + }); + }); + + it('returns undefined for a part with nothing renderable', () => { + expect(partToContent({functionCall: {name: 'roll_die', args: {}}})).toBe( + undefined, + ); + }); +});