diff --git a/README.md b/README.md index aa42b2d64..414eed3f5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ web runtimes. ## 🚀 Installation +> **Prerequisite:** ADK for TypeScript requires a current Node.js LTS release. + ```bash npm install @google/adk npm install -D @google/adk-devtools diff --git a/core/src/agents/routed_agent.ts b/core/src/agents/routed_agent.ts index 18e0d46bf..02a68672d 100644 --- a/core/src/agents/routed_agent.ts +++ b/core/src/agents/routed_agent.ts @@ -61,11 +61,10 @@ export interface RoutedAgentConfig extends BaseAgentConfig { * Routing is strictly limited to the agents passed in the config. * * @remarks - * The inherited {@link BaseAgent.clone} does not support `RoutedAgent`: the - * constructor derives its routing targets from `config.agents` (not - * `subAgents`), so a rebuilt clone re-reads the already-parented originals and - * throws. Cloning a `RoutedAgent` is tracked as a follow-up to - * google/adk-js#534. + * Cloning is supported: {@link RoutedAgent.clone} deep-clones the routing + * targets from `config.agents` and re-parents the fresh copies onto the clone, + * so the clone is a detached root that routes identically to the original while + * leaving the original agent tree untouched. */ @experimental export class RoutedAgent extends BaseAgent { @@ -94,6 +93,30 @@ export class RoutedAgent extends BaseAgent { this.router = config.router; } + /** + * Creates a detached copy of this routed agent. + * + * The inherited {@link BaseAgent.clone} only deep-clones `subAgents`, but a + * `RoutedAgent` derives its routing targets from `config.agents`. Rebuilding + * through the base clone alone would re-read the already-parented original + * agents and throw "already has a parent agent". This override deep-clones the + * routing targets (unless the caller overrides `agents`) so the rebuilt + * constructor re-parents fresh, detached copies. The array-vs-record shape + * and, for records, the keys are preserved so the router keeps selecting the + * same targets. + * + * @param overrides Config fields to override on the clone. Overriding + * `parentAgent` is rejected by the base implementation. + * @returns A new detached `RoutedAgent` of the same concrete class. + */ + override clone(overrides?: Partial): this { + const nextOverrides: Partial = {...overrides}; + if (!('agents' in nextOverrides)) { + nextOverrides.agents = cloneRoutingTargets(this.config.agents); + } + return super.clone(nextOverrides); + } + /** * Runs the selected agent via text-based conversation. */ @@ -116,3 +139,20 @@ export class RoutedAgent extends BaseAgent { ); } } + +/** + * Deep-clones a RoutedAgent's routing targets, preserving whether they were + * supplied as an array or a keyed record so the rebuilt constructor derives the + * same routing map. Each clone is detached (no parent), so the constructor can + * re-parent it without conflict. + */ +function cloneRoutingTargets( + agents: Readonly> | BaseAgent[], +): Readonly> | BaseAgent[] { + if (Array.isArray(agents)) { + return agents.map((agent) => agent.clone()); + } + return Object.fromEntries( + Object.entries(agents).map(([key, agent]) => [key, agent.clone()]), + ); +} diff --git a/core/src/common.ts b/core/src/common.ts index 1e4696b26..4650424e3 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -236,6 +236,7 @@ export type { export {BaseToolset, isBaseToolset} from './tools/base_toolset.js'; export type {ToolPredicate} from './tools/base_toolset.js'; export {ConsolidateContextTool} from './tools/consolidate_context_tool.js'; +export {ExampleTool} from './tools/example_tool.js'; export {EXIT_LOOP, ExitLoopTool} from './tools/exit_loop_tool.js'; export {FunctionTool, isFunctionTool} from './tools/function_tool.js'; export type { @@ -254,6 +255,8 @@ export { LoadArtifactsTool, } from './tools/load_artifacts_tool.js'; export {LOAD_MEMORY, LoadMemoryTool} from './tools/load_memory_tool.js'; +export {LOAD_WEB_PAGE, loadWebPage} from './tools/load_web_page.js'; +export type {LoadWebPageOptions} from './tools/load_web_page.js'; export {LongRunningFunctionTool} from './tools/long_running_tool.js'; export { PRELOAD_MEMORY, diff --git a/core/src/index.ts b/core/src/index.ts index b40796390..21479d8e1 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -55,6 +55,7 @@ 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 * from './tools/mcp/load_mcp_resource_tool.js'; export * from './tools/mcp/mcp_session_manager.js'; export * from './tools/mcp/mcp_tool.js'; export * from './tools/mcp/mcp_toolset.js'; diff --git a/core/src/sessions/vertex_ai_session_service.ts b/core/src/sessions/vertex_ai_session_service.ts index b23279a04..94b7a4201 100644 --- a/core/src/sessions/vertex_ai_session_service.ts +++ b/core/src/sessions/vertex_ai_session_service.ts @@ -7,6 +7,7 @@ import {Client} from '@google-cloud/vertexai/build/src/genai/client.js'; import {Sessions} from '@google-cloud/vertexai/build/src/genai/sessions.js'; import { + EventActions as ApiEventActions, AppendAgentEngineSessionEventConfig, AppendAgentEngineSessionEventRequestParameters, EventMetadata, @@ -40,6 +41,7 @@ import {createSession, Session} from './session.js'; const DEFAULT_MAX_ATTEMPTS = 30; const GRPC_NOT_FOUND = 5; const HTTP_NOT_FOUND = 404; +const HTTP_BAD_REQUEST = 400; /** * Checks if the given URI is a Vertex AI session service URI. @@ -392,12 +394,16 @@ export class VertexAiSessionService extends BaseSessionService { } const config = partialCopy(event, [ - 'content', - 'actions', 'errorCode', 'errorMessage', ]); + const content = event.content && dropUnsupportedPartFields(event.content); + config.content = content; + config.actions = event.actions + ? toApiEventActions(event.actions) + : undefined; + config.eventMetadata = { ...partialCopy(event, [ 'partial', @@ -411,7 +417,7 @@ export class VertexAiSessionService extends BaseSessionService { Object.keys(customMetadata).length > 0 ? customMetadata : undefined, }; - config.rawEvent = JSON.parse(JSON.stringify(event)) as Record< + config.rawEvent = JSON.parse(JSON.stringify({...event, content})) as Record< string, unknown >; @@ -427,24 +433,67 @@ export class VertexAiSessionService extends BaseSessionService { try { await this.sessions.events.append(params); } catch (error) { + if (!isInvalidArgumentError(error)) { + throw error; + } logger.warn( - 'Failed to append event with rawEvent, falling back...', + 'appendEvent was rejected with rawEvent; retrying without it.', error, ); delete config.rawEvent; - await this.sessions.events.append({ - name: `reasoningEngines/${reasoningEngineId}/sessions/${session.id}`, - author: event.author || 'user', - invocationId: event.invocationId || `inv-${Date.now()}`, - timestamp: new Date(event.timestamp).toISOString(), - config, - }); + await this.sessions.events.append(params); } return event; } } +/** + * Returns a copy of `content` without Part fields the Agent Engine Sessions + * API rejects. + * + * `partMetadata` is a Gemini Developer API-only field; the Sessions API fails + * appendEvent with 400 INVALID_ARGUMENT ("Unknown name \"part_metadata\""). + */ +function dropUnsupportedPartFields(content: Content): Content { + if (!content.parts) { + return content; + } + return { + ...content, + parts: content.parts.map((part) => { + const copy = {...part}; + delete copy.partMetadata; + return copy; + }), + }; +} + +/** + * Maps ADK `EventActions` onto the Sessions API wire shape. ADK's + * `transferToAgent` is the API's `transferAgent` (adk-python writes the same + * field as `transfer_agent`); every other field keeps its name, including + * `requestedToolConfirmations`, which the SDK type omits but `_fromApiEvent` + * reads back. + */ +function toApiEventActions(actions: EventActions): ApiEventActions { + const {transferToAgent, ...rest} = actions; + return {...rest, transferAgent: transferToAgent}; +} + +/** + * True when the service rejected the request payload itself, which is what an + * API that does not know `rawEvent` returns. Any other failure must propagate: + * the event may already be persisted, so retrying would append it twice. + * + * The SDK reports HTTP failures as an `ApiError` carrying `status`, matched + * structurally because `core` and `@google-cloud/vertexai` resolve separate + * `@google/genai` copies, making `instanceof` false at runtime. + */ +function isInvalidArgumentError(error: unknown): boolean { + return (error as {status?: number} | null)?.status === HTTP_BAD_REQUEST; +} + interface ExtendedEventActions extends EventActions { compaction?: { startTime: number; @@ -515,7 +564,12 @@ function _fromApiEvent(apiEventObj: VertexAiSessionEvent): Event { 'requestedToolConfirmations' ] as Record) || {}, skipSummarization: actions['skipSummarization'] as boolean | undefined, - transferToAgent: actions['transferAgent'] as string | undefined, + // Earlier adk-js versions copied `event.actions` onto the request + // verbatim, so sessions they wrote store ADK's own `transferToAgent` key. + transferToAgent: (actions['transferAgent'] ?? + (actions as Record)['transferToAgent']) as + | string + | undefined, escalate: actions['escalate'] as boolean | undefined, compaction: compactionData || undefined, }; @@ -535,6 +589,7 @@ function _fromApiEvent(apiEventObj: VertexAiSessionEvent): Event { turnComplete: eventMetadata['turnComplete'] as boolean | undefined, interrupted: eventMetadata['interrupted'] as boolean | undefined, branch: eventMetadata['branch'] as string | undefined, + groundingMetadata: eventMetadata.groundingMetadata, customMetadata, longRunningToolIds: eventMetadata['longRunningToolIds'] as | string[] diff --git a/core/src/tools/example_tool.ts b/core/src/tools/example_tool.ts new file mode 100644 index 000000000..14128c4cb --- /dev/null +++ b/core/src/tools/example_tool.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BaseExampleProvider} from '../examples/base_example_provider.js'; +import {Example} from '../examples/example.js'; +import {buildExampleSi} from '../examples/example_util.js'; +import {appendInstructions} from '../models/llm_request.js'; + +import { + BaseTool, + RunAsyncToolRequest, + ToolProcessLlmRequest, +} from './base_tool.js'; + +/** + * A tool that adds (few-shot) examples to the LLM request. + * + * This tool is executed for each LLM request and is never called by the model; + * it only mutates the outgoing request by appending few-shot instructions built + * from the latest user query. + */ +export class ExampleTool extends BaseTool { + constructor(readonly examples: Example[] | BaseExampleProvider) { + super({ + // Name and description are not used because this tool only changes + // llmRequest. + name: 'example_tool', + description: 'example tool', + }); + } + + override async runAsync(_request: RunAsyncToolRequest): Promise { + // Should not be called by model because it's not declared in LLM tools list. + throw new Error('ExampleTool should not be called by model'); + } + + override async processLlmRequest({ + toolContext, + llmRequest, + }: ToolProcessLlmRequest): Promise { + const parts = toolContext.userContent?.parts; + if (!parts || !parts[0]?.text) { + return; + } + appendInstructions(llmRequest, [ + buildExampleSi(this.examples, parts[0].text, llmRequest.model), + ]); + } +} diff --git a/core/src/tools/load_web_page.ts b/core/src/tools/load_web_page.ts new file mode 100644 index 000000000..053b11fa9 --- /dev/null +++ b/core/src/tools/load_web_page.ts @@ -0,0 +1,316 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {lookup} from 'node:dns/promises'; +import {isIP} from 'node:net'; + +import {z} from 'zod'; + +import {FunctionTool} from './function_tool.js'; + +/** Options for {@link loadWebPage}. */ +export interface LoadWebPageOptions { + /** Request timeout in milliseconds. Defaults to 30_000 (30s). */ + timeoutMs?: number; +} + +/** URL schemes that are allowed to be fetched (WHATWG `URL.protocol` form). */ +const ALLOWED_SCHEMES = new Set(['http:', 'https:']); + +/** Default request timeout in milliseconds. */ +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * IPv4 ranges that are not globally routable and therefore blocked to defeat + * SSRF. Mirrors the non-global ranges rejected by Python's + * `ipaddress.is_global`. + */ +const BLOCKED_IPV4_CIDRS = [ + '0.0.0.0/8', // "this host on this network" + '10.0.0.0/8', // private + '100.64.0.0/10', // shared address space / CGNAT + '127.0.0.0/8', // loopback + '169.254.0.0/16', // link-local (includes GCP metadata 169.254.169.254) + '172.16.0.0/12', // private + '192.0.0.0/24', // IETF protocol assignments + '192.0.2.0/24', // TEST-NET-1 (documentation) + '192.88.99.0/24', // 6to4 relay anycast (deprecated) + '192.168.0.0/16', // private + '198.18.0.0/15', // benchmarking + '198.51.100.0/24', // TEST-NET-2 (documentation) + '203.0.113.0/24', // TEST-NET-3 (documentation) + '224.0.0.0/4', // multicast + '240.0.0.0/4', // reserved / future use (includes 255.255.255.255) +].map(parseIpv4Cidr); + +/** + * IPv6 ranges that are not globally routable and therefore blocked. The + * IPv4-mapped range `::ffff:0:0/96` is handled separately by extracting the + * embedded IPv4 address and re-checking it with the IPv4 rules. + */ +const BLOCKED_IPV6_CIDRS = [ + '::/128', // unspecified + '::1/128', // loopback + '64:ff9b:1::/48', // local NAT64 + '100::/64', // discard-only + '2001:db8::/32', // documentation + 'fc00::/7', // unique-local (ULA, private) + 'fe80::/10', // link-local + 'ff00::/8', // multicast +].map(parseIpv6Cidr); + +/** Builds the parity failure message for a URL. */ +function failedToFetchMessage(url: string): string { + return `Failed to fetch url: ${url}`; +} + +/** + * Returns `true` for `localhost` and any `*.localhost` name (case-insensitive, + * ignoring a trailing dot), matching the Python `_is_blocked_hostname` helper. + */ +function isBlockedHostname(hostname: string): boolean { + const normalized = hostname.replace(/\.+$/, '').toLowerCase(); + return normalized === 'localhost' || normalized.endsWith('.localhost'); +} + +/** Strips the surrounding brackets from an IPv6 URL hostname (`[::1]` → `::1`). */ +function normalizeHost(hostname: string): string { + return hostname.startsWith('[') ? hostname.slice(1, -1) : hostname; +} + +/** Parses a dotted-quad IPv4 string into its four octets, or `null`. */ +function parseIpv4(address: string): number[] | null { + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address); + if (!match) { + return null; + } + const octets = match.slice(1).map(Number); + if (octets.some((octet) => octet > 255)) { + return null; + } + return octets; +} + +/** Expands a valid IPv6 address string into its eight 16-bit hextets, or `null`. */ +function parseIpv6(address: string): number[] | null { + if (isIP(address) !== 6) { + return null; + } + const [head, tail] = address.split('::'); + const highGroups = head ? expandHextets(head) : []; + const lowGroups = tail ? expandHextets(tail) : []; + const compressed = address.includes('::') + ? new Array(8 - highGroups.length - lowGroups.length).fill(0) + : []; + return [...highGroups, ...compressed, ...lowGroups]; +} + +/** + * Converts a colon-separated IPv6 fragment into hextets, expanding a trailing + * embedded IPv4 group (e.g. the `1.2.3.4` in `::ffff:1.2.3.4`) into two hextets. + */ +function expandHextets(fragment: string): number[] { + const hextets: number[] = []; + for (const group of fragment.split(':')) { + if (group.includes('.')) { + const octets = parseIpv4(group)!; + hextets.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]); + } else { + hextets.push(parseInt(group, 16)); + } + } + return hextets; +} + +/** Packs four IPv4 octets into an unsigned 32-bit integer. */ +function ipv4ToInt(octets: number[]): number { + return ( + ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0 + ); +} + +/** Packs eight IPv6 hextets into a 128-bit BigInt. */ +function hextetsToBigInt(hextets: number[]): bigint { + let value = 0n; + for (const hextet of hextets) { + value = (value << 16n) | BigInt(hextet); + } + return value; +} + +/** Precomputes the network address and mask for an IPv4 CIDR string. */ +function parseIpv4Cidr(cidr: string): {base: number; mask: number} { + const [address, prefix] = cidr.split('/'); + const mask = (0xffffffff << (32 - Number(prefix))) >>> 0; + return {base: (ipv4ToInt(parseIpv4(address)!) & mask) >>> 0, mask}; +} + +/** Precomputes the network address and prefix length for an IPv6 CIDR string. */ +function parseIpv6Cidr(cidr: string): {base: bigint; prefix: number} { + const [address, prefix] = cidr.split('/'); + return {base: hextetsToBigInt(parseIpv6(address)!), prefix: Number(prefix)}; +} + +/** Returns `true` if the IPv4 octets fall within any blocked range. */ +function isBlockedIpv4(octets: number[]): boolean { + const value = ipv4ToInt(octets); + return BLOCKED_IPV4_CIDRS.some( + ({base, mask}) => (value & mask) >>> 0 === base, + ); +} + +/** Returns `true` if the IPv6 hextets fall within any blocked range. */ +function isBlockedIpv6(hextets: number[]): boolean { + const value = hextetsToBigInt(hextets); + // IPv4-mapped (::ffff:0:0/96): re-check the embedded IPv4 address. + if (value >> 32n === 0xffffn) { + return isBlockedIpv4([ + Number((value >> 24n) & 0xffn), + Number((value >> 16n) & 0xffn), + Number((value >> 8n) & 0xffn), + Number(value & 0xffn), + ]); + } + return BLOCKED_IPV6_CIDRS.some( + ({base, prefix}) => + value >> BigInt(128 - prefix) === base >> BigInt(128 - prefix), + ); +} + +/** + * Returns `true` when `address` is not globally routable (private, loopback, + * link-local, shared, reserved, multicast, ...). Unparseable input fails + * closed (blocked). + */ +function isBlockedAddress(address: string): boolean { + const octets = parseIpv4(address); + if (octets) { + return isBlockedIpv4(octets); + } + const hextets = parseIpv6(address); + if (hextets) { + return isBlockedIpv6(hextets); + } + return true; +} + +/** + * Resolves `hostname` to a de-duplicated list of IP addresses. IP literals are + * returned as-is; hostnames are resolved via DNS. Throws when resolution + * yields no address. + */ +async function resolveHostAddresses(hostname: string): Promise { + if (isIP(hostname) !== 0) { + return [hostname]; + } + const records = await lookup(hostname, {all: true}); + const addresses = [...new Set(records.map((record) => record.address))]; + if (addresses.length === 0) { + throw new Error(`Unable to resolve host: ${hostname}`); + } + return addresses; +} + +/** + * Validates the URL's scheme and hostname up front (before any network access). + * Throws for malformed URLs, disallowed schemes, and blocked hostnames. + */ +function assertUrlAllowed(url: string): URL { + const parsed = new URL(url); + if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + throw new Error(`Unsupported url scheme: ${url}`); + } + if (isBlockedHostname(parsed.hostname)) { + throw new Error(`Blocked host: ${parsed.hostname}`); + } + return parsed; +} + +/** Resolves the host and throws if any resolved address is not globally routable. */ +async function validateResolvedAddresses(hostname: string): Promise { + const addresses = await resolveHostAddresses(hostname); + if (addresses.some(isBlockedAddress)) { + throw new Error(`Blocked host: ${hostname}`); + } +} + +/** Decodes the small set of HTML entities that survive tag stripping. */ +function decodeHtmlEntities(text: string): string { + return text + .replace(/ /g, ' ') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +/** + * Extracts readable text from an HTML document. Removes `' + + '' + + '

Fish & chips are quite tasty today

', + ), + ); + + const result = await loadWebPage('https://example.com/'); + + expect(result).toBe('Fish & chips are quite tasty today'); + expect(result).not.toContain('secret'); + expect(result).not.toContain('color:red'); + expect(result).not.toContain('comment'); + }); + + it('allows a global IPv6 literal target', async () => { + fetchMock.mockResolvedValue( + htmlResponse('

The quick brown fox jumped over here

'), + ); + + const result = await loadWebPage('http://[2606:4700:4700::1111]/'); + + expect(result).toBe('The quick brown fox jumped over here'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(lookupMock).not.toHaveBeenCalled(); + }); + + it('allows a global IPv6 address resolved via DNS (full form)', async () => { + resolveTo('2606:4700:4700:0:0:0:0:1111'); + fetchMock.mockResolvedValue( + htmlResponse('

The quick brown fox jumped over here

'), + ); + + const result = await loadWebPage('http://ipv6.example/'); + + expect(result).toBe('The quick brown fox jumped over here'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('allows an IPv4-mapped IPv6 address pointing at a public IP', async () => { + resolveTo('::ffff:93.184.216.34'); + fetchMock.mockResolvedValue( + htmlResponse('

The quick brown fox jumped over here

'), + ); + + const result = await loadWebPage('http://mapped-public.example/'); + + expect(result).toBe('The quick brown fox jumped over here'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('returns an empty string when no line has enough words', async () => { + resolveTo('93.184.216.34'); + fetchMock.mockResolvedValue(htmlResponse('

too short

')); + + const result = await loadWebPage('https://example.com/'); + + expect(result).toBe(''); + }); + }); + + describe('response and transport failures', () => { + it.each([301, 302, 404, 500])( + 'returns the failure string for non-200 status %i', + async (status) => { + resolveTo('93.184.216.34'); + fetchMock.mockResolvedValue( + htmlResponse('

ignored body here

', status), + ); + + const result = await loadWebPage('https://example.com/'); + + expect(result).toBe('Failed to fetch url: https://example.com/'); + }, + ); + + it('returns the failure string when the request times out', async () => { + resolveTo('93.184.216.34'); + fetchMock.mockRejectedValue( + new DOMException('The operation timed out.', 'TimeoutError'), + ); + + const result = await loadWebPage('https://example.com/'); + + expect(result).toBe('Failed to fetch url: https://example.com/'); + }); + + it('returns the failure string on a network error', async () => { + resolveTo('93.184.216.34'); + fetchMock.mockRejectedValue(new TypeError('network failure')); + + const result = await loadWebPage('https://example.com/'); + + expect(result).toBe('Failed to fetch url: https://example.com/'); + }); + }); + + describe('timeout configuration', () => { + it('uses the 30s default and honors an override', async () => { + resolveTo('93.184.216.34'); + fetchMock.mockResolvedValue( + htmlResponse('

enough words to be kept here

'), + ); + + await loadWebPage('https://example.com/'); + expect(vi.mocked(AbortSignal.timeout)).toHaveBeenLastCalledWith(30_000); + + await loadWebPage('https://example.com/', {timeoutMs: 5000}); + expect(vi.mocked(AbortSignal.timeout)).toHaveBeenLastCalledWith(5000); + }); + + it('falls back to the default when options omit timeoutMs', async () => { + resolveTo('93.184.216.34'); + fetchMock.mockResolvedValue( + htmlResponse('

enough words to be kept here

'), + ); + + await loadWebPage('https://example.com/', {}); + + expect(vi.mocked(AbortSignal.timeout)).toHaveBeenLastCalledWith(30_000); + }); + }); +}); + +describe('LOAD_WEB_PAGE tool', () => { + it('is a FunctionTool exposing a load_web_page declaration', () => { + expect(LOAD_WEB_PAGE).toBeInstanceOf(FunctionTool); + + const declaration = LOAD_WEB_PAGE._getDeclaration(); + expect(declaration?.name).toBe('load_web_page'); + expect(declaration?.parameters?.properties?.['url']).toBeDefined(); + }); + + it('runs through the tool interface and returns the parity failure string', async () => { + const result = await LOAD_WEB_PAGE.runAsync({ + args: {url: 'file:///etc/passwd'}, + toolContext: {} as never, + }); + + expect(result).toBe('Failed to fetch url: file:///etc/passwd'); + }); +}); diff --git a/core/test/tools/mcp/load_mcp_resource_tool_test.ts b/core/test/tools/mcp/load_mcp_resource_tool_test.ts new file mode 100644 index 000000000..6d3039052 --- /dev/null +++ b/core/test/tools/mcp/load_mcp_resource_tool_test.ts @@ -0,0 +1,273 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Context, + LlmRequest, + LoadMcpResourceTool, + MCPToolset, + RunAsyncToolRequest, +} from '@google/adk'; +import {Content, Type} from '@google/genai'; +import {beforeEach, describe, expect, it, Mock, vi} from 'vitest'; + +/** + * Builds a {@link LoadMcpResourceTool} backed by a minimal mock toolset. Only + * `listResources`/`readResource` are exercised by the tool, so those are the + * only methods stubbed. + */ +function setup() { + const listResources = vi.fn().mockResolvedValue([] as string[]); + const readResource = vi.fn().mockResolvedValue([]); + const toolset = {listResources, readResource} as unknown as MCPToolset; + const tool = new LoadMcpResourceTool(toolset); + return {tool, listResources, readResource}; +} + +/** A throwaway tool context; the tool never reads from it. */ +const toolContext = {} as unknown as Context; + +/** Builds a bare LlmRequest suitable for `processLlmRequest`. */ +function makeLlmRequest(contents: Content[] = []): LlmRequest { + return {contents, toolsDict: {}} as unknown as LlmRequest; +} + +/** Builds a content whose first part is a `load_mcp_resource` function response. */ +function functionResponseContent(response: Record): Content { + return { + role: 'user', + parts: [{functionResponse: {name: 'load_mcp_resource', response}}], + }; +} + +describe('LoadMcpResourceTool', () => { + let listResources: Mock; + let readResource: Mock; + let tool: LoadMcpResourceTool; + + beforeEach(() => { + ({tool, listResources, readResource} = setup()); + }); + + it('initializes with the load_mcp_resource name', () => { + expect(tool.name).toBe('load_mcp_resource'); + }); + + describe('_getDeclaration', () => { + it('declares a resource_names array-of-strings parameter', () => { + const declaration = tool._getDeclaration(); + + expect(declaration.name).toBe('load_mcp_resource'); + const resourceNames = + declaration.parameters?.properties?.['resource_names']; + expect(resourceNames?.type).toBe(Type.ARRAY); + expect(resourceNames?.items?.type).toBe(Type.STRING); + }); + }); + + describe('runAsync', () => { + it('echoes the requested resource names with a status', async () => { + const result = (await tool.runAsync({ + args: {resource_names: ['res1', 'res2']}, + toolContext, + })) as {resource_names: string[]; status: string}; + + expect(result.resource_names).toEqual(['res1', 'res2']); + expect(result.status).toContain('temporarily inserted'); + }); + + it('defaults resource_names to an empty array when absent', async () => { + const result = (await tool.runAsync({ + args: {}, + toolContext, + } as RunAsyncToolRequest)) as {resource_names: string[]}; + + expect(result.resource_names).toEqual([]); + }); + }); + + describe('processLlmRequest', () => { + it('injects the resource list into the system instruction', async () => { + listResources.mockResolvedValue(['res1', 'res2']); + const llmRequest = makeLlmRequest([]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.config?.systemInstruction).toContain('res1'); + expect(llmRequest.config?.systemInstruction).toContain('res2'); + }); + + it('does not inject instructions when there are no resources', async () => { + listResources.mockResolvedValue([]); + const llmRequest = makeLlmRequest([]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.config?.systemInstruction).toBeUndefined(); + }); + + it('swallows list errors and still processes function responses', async () => { + listResources.mockRejectedValue(new Error('list failed')); + readResource.mockResolvedValue([ + {uri: 'file:///res1', mimeType: 'text/plain', text: 'hello content'}, + ]); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await expect( + tool.processLlmRequest({toolContext, llmRequest}), + ).resolves.toBeUndefined(); + + expect(llmRequest.contents).toHaveLength(2); + }); + + it('appends text resource content', async () => { + readResource.mockResolvedValue([ + {uri: 'file:///res1', mimeType: 'text/plain', text: 'hello content'}, + ]); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(readResource).toHaveBeenCalledWith('res1'); + expect(llmRequest.contents).toHaveLength(2); + const appended = llmRequest.contents[1]; + expect(appended.role).toBe('user'); + expect(appended.parts?.[0].text).toBe('Resource res1 is:'); + expect(appended.parts?.[1].text).toBe('hello content'); + }); + + it('appends binary resource content without decoding the base64 blob', async () => { + const blob = Buffer.from('binary data').toString('base64'); + readResource.mockResolvedValue([ + {uri: 'file:///res1', mimeType: 'image/png', blob}, + ]); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + const part = llmRequest.contents[1].parts?.[1]; + expect(part?.inlineData?.data).toBe(blob); + expect(part?.inlineData?.mimeType).toBe('image/png'); + }); + + it('defaults the mime type for binary content that lacks one', async () => { + const blob = Buffer.from('binary data').toString('base64'); + readResource.mockResolvedValue([{uri: 'file:///res1', blob}]); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + const part = llmRequest.contents[1].parts?.[1]; + expect(part?.inlineData?.mimeType).toBe('application/octet-stream'); + }); + + it('renders a placeholder for unknown content types', async () => { + readResource.mockResolvedValue([{uri: 'file:///res1'}]); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents[1].parts?.[1].text).toContain( + 'Unknown content type', + ); + }); + + it('swallows read errors and appends nothing for that resource', async () => { + readResource.mockRejectedValue(new Error('read failed')); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await expect( + tool.processLlmRequest({toolContext, llmRequest}), + ).resolves.toBeUndefined(); + + expect(llmRequest.contents).toHaveLength(1); + }); + + it('does nothing when the last content is not a matching function response', async () => { + const llmRequest = makeLlmRequest([ + { + role: 'user', + parts: [{functionResponse: {name: 'other_tool', response: {}}}], + }, + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(1); + expect(readResource).not.toHaveBeenCalled(); + }); + + it('does nothing when the last content has no parts', async () => { + const llmRequest = makeLlmRequest([{role: 'user'}]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(1); + expect(readResource).not.toHaveBeenCalled(); + }); + + it('does nothing when the last content has an empty parts array', async () => { + const llmRequest = makeLlmRequest([{role: 'user', parts: []}]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(1); + expect(readResource).not.toHaveBeenCalled(); + }); + + it('does nothing when the first part is not a function response', async () => { + const llmRequest = makeLlmRequest([ + {role: 'user', parts: [{text: 'just text'}]}, + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(1); + expect(readResource).not.toHaveBeenCalled(); + }); + + it('reads nothing when the function response omits resource_names', async () => { + const llmRequest = makeLlmRequest([ + { + role: 'user', + parts: [{functionResponse: {name: 'load_mcp_resource'}}], + }, + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(1); + expect(readResource).not.toHaveBeenCalled(); + }); + + it('appends content after a preceding conversation turn', async () => { + readResource.mockResolvedValue([ + {uri: 'file:///res1', mimeType: 'text/plain', text: 'hello content'}, + ]); + const llmRequest = makeLlmRequest([ + {role: 'user', parts: [{text: 'earlier message'}]}, + functionResponseContent({resource_names: ['res1']}), + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(3); + expect(llmRequest.contents[2].parts?.[1].text).toBe('hello content'); + }); + }); +}); diff --git a/core/test/tools/mcp/mcp_toolset_test.ts b/core/test/tools/mcp/mcp_toolset_test.ts index f875572d9..200ef8f8b 100644 --- a/core/test/tools/mcp/mcp_toolset_test.ts +++ b/core/test/tools/mcp/mcp_toolset_test.ts @@ -25,10 +25,24 @@ vi.mock('@modelcontextprotocol/sdk/client/index.js', () => { {name: 'other-tool', description: 'Another tool', inputSchema: {}}, ], }), + listResources: vi.fn().mockResolvedValue({ + resources: [ + {uri: 'file:///res1', name: 'res1'}, + {uri: 'file:///res2', name: 'res2'}, + ], + }), + readResource: vi.fn().mockResolvedValue({ + contents: [ + {uri: 'file:///res1', mimeType: 'text/plain', text: 'hello'}, + ], + }), })), }; }); +/** A client method stub that resolves to nothing (connect/close). */ +const noop = () => vi.fn().mockResolvedValue(undefined); + vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => { return { StdioClientTransport: vi.fn(), @@ -149,4 +163,174 @@ describe('MCPToolset', () => { expect(toolset['mcpSessionManager'].getActiveSessions()).toHaveLength(0); }); }); + + describe('resources', () => { + it('listResources returns the mapped resource names', async () => { + const toolset = new MCPToolset(stdioParams); + + const names = await toolset.listResources(); + + expect(names).toEqual(['res1', 'res2']); + }); + + it('getResourceInfo returns the matching resource', async () => { + const toolset = new MCPToolset(stdioParams); + + const info = await toolset.getResourceInfo('res1'); + + expect(info.name).toBe('res1'); + expect(info.uri).toBe('file:///res1'); + }); + + it('getResourceInfo rejects when the name is unknown', async () => { + const toolset = new MCPToolset(stdioParams); + + await expect(toolset.getResourceInfo('nope')).rejects.toThrow( + "Resource with name 'nope' not found.", + ); + }); + + it('readResource resolves the URI and returns the contents', async () => { + const {Client} = + await import('@modelcontextprotocol/sdk/client/index.js'); + const readResource = vi.fn().mockResolvedValue({ + contents: [{uri: 'file:///res1', text: 'hello'}], + }); + vi.mocked(Client) + .mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + listResources: vi.fn().mockResolvedValue({ + resources: [{uri: 'file:///res1', name: 'res1'}], + }), + }) as unknown as Client, + ) + .mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + readResource, + }) as unknown as Client, + ); + + const toolset = new MCPToolset(stdioParams); + const contents = await toolset.readResource('res1'); + + expect(readResource).toHaveBeenCalledWith({uri: 'file:///res1'}); + expect(contents).toEqual([{uri: 'file:///res1', text: 'hello'}]); + }); + + it('readResource rejects when the name is unknown', async () => { + const toolset = new MCPToolset(stdioParams); + + await expect(toolset.readResource('nope')).rejects.toThrow( + "Resource with name 'nope' not found.", + ); + }); + + it('readResource rejects when the resolved resource has no URI', async () => { + const {Client} = + await import('@modelcontextprotocol/sdk/client/index.js'); + vi.mocked(Client).mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + listResources: vi.fn().mockResolvedValue({ + resources: [{uri: '', name: 'res1'}], + }), + }) as unknown as Client, + ); + + const toolset = new MCPToolset(stdioParams); + + await expect(toolset.readResource('res1')).rejects.toThrow( + "Resource 'res1' has no URI.", + ); + }); + + describe('cleanup', () => { + it('closes the session after listResources succeeds', async () => { + const toolset = new MCPToolset(stdioParams); + const spy = vi.spyOn(toolset['mcpSessionManager'], 'closeSession'); + + await toolset.listResources(); + + expect(spy).toHaveBeenCalledOnce(); + expect(toolset['mcpSessionManager'].getActiveSessions()).toHaveLength( + 0, + ); + }); + + it('closes the session even if the client listResources rejects', async () => { + const {Client} = + await import('@modelcontextprotocol/sdk/client/index.js'); + vi.mocked(Client).mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + listResources: vi.fn().mockRejectedValue(new Error('list boom')), + }) as unknown as Client, + ); + + const toolset = new MCPToolset(stdioParams); + const spy = vi.spyOn(toolset['mcpSessionManager'], 'closeSession'); + + await expect(toolset.listResources()).rejects.toThrow('list boom'); + expect(spy).toHaveBeenCalledOnce(); + expect(toolset['mcpSessionManager'].getActiveSessions()).toHaveLength( + 0, + ); + }); + + it('closes both sessions after readResource succeeds', async () => { + const toolset = new MCPToolset(stdioParams); + const spy = vi.spyOn(toolset['mcpSessionManager'], 'closeSession'); + + await toolset.readResource('res1'); + + expect(spy).toHaveBeenCalledTimes(2); + expect(toolset['mcpSessionManager'].getActiveSessions()).toHaveLength( + 0, + ); + }); + + it('closes both sessions even if the client readResource rejects', async () => { + const {Client} = + await import('@modelcontextprotocol/sdk/client/index.js'); + vi.mocked(Client) + .mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + listResources: vi.fn().mockResolvedValue({ + resources: [{uri: 'file:///res1', name: 'res1'}], + }), + }) as unknown as Client, + ) + .mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + readResource: vi.fn().mockRejectedValue(new Error('read boom')), + }) as unknown as Client, + ); + + const toolset = new MCPToolset(stdioParams); + const spy = vi.spyOn(toolset['mcpSessionManager'], 'closeSession'); + + await expect(toolset.readResource('res1')).rejects.toThrow('read boom'); + expect(spy).toHaveBeenCalledTimes(2); + expect(toolset['mcpSessionManager'].getActiveSessions()).toHaveLength( + 0, + ); + }); + }); + }); }); diff --git a/core/test/tools/openapi_tool/tool_auth_handler_test.ts b/core/test/tools/openapi_tool/tool_auth_handler_test.ts index a493e7971..2a718e4a5 100644 --- a/core/test/tools/openapi_tool/tool_auth_handler_test.ts +++ b/core/test/tools/openapi_tool/tool_auth_handler_test.ts @@ -4,9 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {AuthCredentialTypes, Context, ToolAuthHandler} from '@google/adk'; +import { + AuthCredential, + AuthCredentialTypes, + Context, + ToolAuthHandler, +} from '@google/adk'; import {describe, expect, it, vi} from 'vitest'; import {State} from '../../../src/sessions/state.js'; +import {AutoAuthCredentialExchanger} from '../../../src/tools/openapi_tool/auth/credential_exchangers/auto_auth_credential_exchanger.js'; // Mock AutoAuthCredentialExchanger vi.mock( @@ -147,4 +153,94 @@ describe('ToolAuthHandler', () => { // The cached credential was reused; no second exchange was triggered. expect(secondContext.getAuthResponse).not.toHaveBeenCalled(); }); + + it('uses the credential the tool was configured with instead of requesting one', async () => { + const mockContext = { + state: new State(), + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + } as unknown as Context; + + const result = await new ToolAuthHandler( + mockContext, + {type: 'apiKey', name: 'X-API-Key', in: 'header'}, + {authType: AuthCredentialTypes.API_KEY, apiKey: 'static-key'}, + ).prepareAuthCredentials(); + + // Schemes like apiKey need no user interaction, so asking the client for a + // credential would leave the tool stuck in `pending` forever. + expect(result.state).toBe('done'); + expect(mockContext.requestCredential).not.toHaveBeenCalled(); + }); + + it('does not copy a static credential that needed no exchange into session state', async () => { + const staticCredential: AuthCredential = { + authType: AuthCredentialTypes.API_KEY, + apiKey: 'static-key', + }; + // The real exchanger has no exchanger registered for apiKey/http, so it + // hands the credential straight back. + vi.mocked(AutoAuthCredentialExchanger).mockImplementationOnce( + () => + ({ + exchange: vi.fn().mockResolvedValue({ + credential: staticCredential, + wasExchanged: false, + }), + }) as unknown as AutoAuthCredentialExchanger, + ); + + const state = new State(); + const mockContext = { + state, + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + } as unknown as Context; + + const result = await new ToolAuthHandler( + mockContext, + {type: 'apiKey', name: 'X-API-Key', in: 'header'}, + staticCredential, + ).prepareAuthCredentials(); + + expect(result.state).toBe('done'); + expect(result.authCredential?.apiKey).toBe('static-key'); + // It is readable from the tool on every invocation, so persisting it would + // only write the secret into the session store for nothing. + expect(state.get('apiKey_existing_exchanged_credential')).toBeUndefined(); + expect(state.hasDelta()).toBe(false); + }); + + it('caches a static credential that did require an exchange', async () => { + const state = new State(); + const mockContext = { + state, + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + } as unknown as Context; + + const result = await new ToolAuthHandler( + mockContext, + { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/token', + scopes: {}, + }, + }, + }, + { + authType: AuthCredentialTypes.OAUTH2, + oauth2: {clientId: 'client-id', clientSecret: 'client-secret'}, + }, + ).prepareAuthCredentials(); + + expect(result.state).toBe('done'); + // An exchange costs a round trip, so its result is worth persisting. + const stored = state.get<{http?: {credentials: {token: string}}}>( + 'oauth2_existing_exchanged_credential', + ); + expect(stored?.http?.credentials.token).toBe('exchanged-token'); + }); }); diff --git a/tests/e2e/tools/mcp/load_mcp_resource_e2e_test.ts b/tests/e2e/tools/mcp/load_mcp_resource_e2e_test.ts new file mode 100644 index 000000000..c7b628921 --- /dev/null +++ b/tests/e2e/tools/mcp/load_mcp_resource_e2e_test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Context, + LlmRequest, + LoadMcpResourceTool, + MCPToolset, +} from '@google/adk'; +import {fileURLToPath} from 'node:url'; +import {afterEach, describe, expect, it} from 'vitest'; + +/** + * End-to-end test with NO mocks: a real `MCPToolset` talks to a real MCP server + * (spawned as a stdio child process, see `mcp_resource_server.mjs`) that exposes + * a text and a binary resource. This proves the resource path works against an + * actual MCP server, not just against test doubles. + */ + +const SERVER_PATH = fileURLToPath( + new URL('./mcp_resource_server.mjs', import.meta.url), +); + +/** A throwaway tool context; the tool never reads from it. */ +const toolContext = {} as unknown as Context; + +function createToolset(): MCPToolset { + return new MCPToolset({ + type: 'StdioConnectionParams', + serverParams: {command: process.execPath, args: [SERVER_PATH]}, + }); +} + +function functionResponseRequest(resourceNames: string[]): LlmRequest { + return { + contents: [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'load_mcp_resource', + response: {resource_names: resourceNames}, + }, + }, + ], + }, + ], + toolsDict: {}, + } as unknown as LlmRequest; +} + +describe('LoadMcpResourceTool (e2e, real MCP server over stdio)', () => { + let toolset: MCPToolset; + + afterEach(async () => { + await toolset?.close(); + }); + + it('lists, resolves, and reads real MCP resources', async () => { + toolset = createToolset(); + + const names = await toolset.listResources(); + expect(names).toEqual(expect.arrayContaining(['readme', 'logo'])); + + const info = await toolset.getResourceInfo('readme'); + expect(info.uri).toBe('file:///readme.txt'); + + const textContents = await toolset.readResource('readme'); + expect(textContents[0]).toMatchObject({text: 'hello from mcp resource'}); + + const binaryContents = await toolset.readResource('logo'); + expect(binaryContents[0]).toMatchObject({ + blob: Buffer.from('binary-logo-bytes').toString('base64'), + mimeType: 'image/png', + }); + }); + + it('rejects when reading an unknown resource', async () => { + toolset = createToolset(); + + await expect(toolset.readResource('does-not-exist')).rejects.toThrow( + 'not found', + ); + }); + + it('injects real resource contents into the LlmRequest via the tool', async () => { + toolset = createToolset(); + const tool = new LoadMcpResourceTool(toolset); + const llmRequest = functionResponseRequest(['readme', 'logo']); + + await tool.processLlmRequest({toolContext, llmRequest}); + + // The server advertises the resources, so the guidance is injected. + expect(llmRequest.config?.systemInstruction).toContain('readme'); + + // The original function-response turn plus one appended turn per resource. + expect(llmRequest.contents).toHaveLength(3); + + const textTurn = llmRequest.contents[1]; + expect(textTurn.role).toBe('user'); + expect(textTurn.parts?.[0].text).toBe('Resource readme is:'); + expect(textTurn.parts?.[1].text).toBe('hello from mcp resource'); + + const binaryTurn = llmRequest.contents[2]; + expect(binaryTurn.parts?.[0].text).toBe('Resource logo is:'); + expect(binaryTurn.parts?.[1].inlineData?.mimeType).toBe('image/png'); + expect(binaryTurn.parts?.[1].inlineData?.data).toBe( + Buffer.from('binary-logo-bytes').toString('base64'), + ); + }); +}); diff --git a/tests/e2e/tools/mcp/mcp_resource_server.mjs b/tests/e2e/tools/mcp/mcp_resource_server.mjs new file mode 100644 index 000000000..cdf6b7ce8 --- /dev/null +++ b/tests/e2e/tools/mcp/mcp_resource_server.mjs @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A minimal, real MCP server exposing two resources (one text, one binary) over + * stdio. It is spawned as a child process by the LoadMcpResourceTool e2e test to + * exercise the resource path end-to-end with no mocks. + */ + +import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; + +const server = new McpServer({name: 'e2e-resource-server', version: '1.0.0'}); + +server.registerResource( + 'readme', + 'file:///readme.txt', + {mimeType: 'text/plain'}, + async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: 'text/plain', + text: 'hello from mcp resource', + }, + ], + }), +); + +server.registerResource( + 'logo', + 'file:///logo.png', + {mimeType: 'image/png'}, + async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: 'image/png', + blob: Buffer.from('binary-logo-bytes').toString('base64'), + }, + ], + }), +); + +const transport = new StdioServerTransport(); +await server.connect(transport);