From 80848e799f0beafc010ddb07cf3c54ed77371dc8 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 10:54:54 -0700 Subject: [PATCH 1/7] chore(core): add @toolbox-sdk/adk as an optional peer dependency The MCP Toolbox JavaScript SDK is declared as an optional peer plus a devDependency rather than a regular dependency: it depends on @google/adk itself, so a regular dependency would make the published graph cyclic. The devDependency is what makes types and test mocks resolve in-repo, mirroring Python's opt-in `google-adk[toolbox]` extra. --- core/package.json | 9 ++++- package-lock.json | 90 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/core/package.json b/core/package.json index 678f8c0c9..99921e136 100644 --- a/core/package.json +++ b/core/package.json @@ -72,6 +72,7 @@ }, "devDependencies": { "@mikro-orm/sqlite": "^6.6.6", + "@toolbox-sdk/adk": "^1.1.0", "@types/adm-zip": "^0.5.8", "@types/express": "^4.17.25", "@types/lodash-es": "^4.17.12", @@ -82,6 +83,12 @@ "@mikro-orm/mssql": "^6.6.6", "@mikro-orm/mysql": "^6.6.6", "@mikro-orm/postgresql": "^6.6.6", - "@mikro-orm/sqlite": "^6.6.6" + "@mikro-orm/sqlite": "^6.6.6", + "@toolbox-sdk/adk": "^1.1.0" + }, + "peerDependenciesMeta": { + "@toolbox-sdk/adk": { + "optional": true + } } } diff --git a/package-lock.json b/package-lock.json index e29a4a190..e5ee1b4b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ }, "devDependencies": { "@mikro-orm/sqlite": "^6.6.6", + "@toolbox-sdk/adk": "^1.1.0", "@types/adm-zip": "^0.5.8", "@types/express": "^4.17.25", "@types/lodash-es": "^4.17.12", @@ -86,7 +87,13 @@ "@mikro-orm/mssql": "^6.6.6", "@mikro-orm/mysql": "^6.6.6", "@mikro-orm/postgresql": "^6.6.6", - "@mikro-orm/sqlite": "^6.6.6" + "@mikro-orm/sqlite": "^6.6.6", + "@toolbox-sdk/adk": "^1.1.0" + }, + "peerDependenciesMeta": { + "@toolbox-sdk/adk": { + "optional": true + } } }, "dev": { @@ -3933,6 +3940,50 @@ "@textlint/ast-node-types": "15.7.1" } }, + "node_modules/@toolbox-sdk/adk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@toolbox-sdk/adk/-/adk-1.1.0.tgz", + "integrity": "sha512-VTNq4oZq1TZ8OjPaV4KnWMyg8HYWL1EUepo/zdkEVSS2mOJLaQq5th7/Cv6cDn8FX7kuJddKLk/S8bBEwdvjPA==", + "dev": true, + "dependencies": { + "@google/adk": "^1.0.0", + "@google/genai": "^2.0.0", + "@modelcontextprotocol/sdk": "1.29.0", + "@toolbox-sdk/core": "^1.1.0", + "axios": "^1.16.0", + "openapi-types": "^12.1.3", + "zod": "^3.24.4" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@toolbox-sdk/adk/node_modules/@toolbox-sdk/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@toolbox-sdk/core/-/core-1.1.0.tgz", + "integrity": "sha512-CqDxoUODbdqVrqd9ltlLKXAu+6TPGQ5acdPed+6LtlttLaUwX4I2wllJWPhuPOozhH1PNgiW1JViU9tYi7OeIg==", + "dev": true, + "dependencies": { + "axios": "^1.16.0", + "google-auth-library": "^10.0.0", + "uuid": "^11.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "zod": "^3.24.4" + } + }, + "node_modules/@toolbox-sdk/adk/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -5583,6 +5634,34 @@ "node": ">= 6.0.0" } }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -11908,6 +11987,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", From 040035d55c24528d4f3b7371325196b16856e0ed Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 10:56:58 -0700 Subject: [PATCH 2/7] feat(core): add ToolboxToolset for MCP Toolbox for Databases Ports adk-python's ToolboxToolset. Like the Python class, this is a thin adapter over the separately published toolbox SDK rather than a hand-rolled protocol client: the SDK is loaded through a dynamic import so the package stays optional, and a missing install surfaces as an actionable error. Loaded tools are re-wrapped in a module-private BaseTool adapter instead of being returned directly, so the tools carry this package's BaseTool brand even when the SDK resolved a different @google/adk copy. --- core/src/common.ts | 6 + core/src/tools/toolbox_toolset.ts | 204 ++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 core/src/tools/toolbox_toolset.ts diff --git a/core/src/common.ts b/core/src/common.ts index 23f628165..e41cfeda7 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -269,6 +269,12 @@ export { } from './tools/preload_memory_tool.js'; export {requestInputTool} from './tools/request_input_tool.js'; export {ToolConfirmation} from './tools/tool_confirmation.js'; +export {ToolboxToolset} from './tools/toolbox_toolset.js'; +export type { + ToolboxAuthTokenGetter, + ToolboxBoundValue, + ToolboxToolsetOptions, +} from './tools/toolbox_toolset.js'; export {URL_CONTEXT, UrlContextTool} from './tools/url_context_tool.js'; export {VertexAiSearchTool} from './tools/vertex_ai_search_tool.js'; export type { diff --git a/core/src/tools/toolbox_toolset.ts b/core/src/tools/toolbox_toolset.ts new file mode 100644 index 000000000..51925730e --- /dev/null +++ b/core/src/tools/toolbox_toolset.ts @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FunctionDeclaration} from '@google/genai'; +import type { + ToolboxClient as ToolboxSdkClient, + ToolboxTool as ToolboxSdkTool, +} from '@toolbox-sdk/adk'; + +import {ReadonlyContext} from '../agents/readonly_context.js'; + +import {BaseTool, RunAsyncToolRequest} from './base_tool.js'; +import {BaseToolset} from './base_toolset.js'; + +/** The npm package that backs {@link ToolboxToolset}. */ +const TOOLBOX_SDK_PACKAGE = '@toolbox-sdk/adk'; + +/** + * Produces an authentication token for a single named auth source. + * + * The getter is handed to the toolbox SDK, which invokes it on every tool + * call, so a short-lived token can be refreshed between calls. + */ +export type ToolboxAuthTokenGetter = () => string | Promise; + +/** + * A value bound to a tool parameter: either a literal, or a callable that + * produces one when the tool is invoked. + */ +export type ToolboxBoundValue = + | unknown + | (() => unknown) + | (() => Promise); + +/** + * Options for {@link ToolboxToolset}. + * + * `toolsetName` and `toolNames` are both optional selectors. If both are + * omitted, every tool on the server is loaded. + */ +export interface ToolboxToolsetOptions { + /** + * The name of a toolset defined on the server. Its tools are loaded in + * addition to any listed in {@link ToolboxToolsetOptions.toolNames}. + */ + toolsetName?: string; + + /** + * Names of individual tools to load, in addition to any loaded through + * {@link ToolboxToolsetOptions.toolsetName}. + */ + toolNames?: string[]; + + /** + * Maps an auth source name to a getter returning its token. See + * https://github.com/googleapis/mcp-toolbox-sdk-js/tree/main/packages/toolbox-core#authenticating-tools + */ + authTokenGetters?: Record; + + /** + * Maps a tool parameter name to a value that is pre-filled on every call + * and hidden from the model. See + * https://github.com/googleapis/mcp-toolbox-sdk-js/tree/main/packages/toolbox-core#binding-parameter-values + */ + boundParams?: Record; + + /** Static headers sent with every request to the toolbox server. */ + additionalHeaders?: Record; +} + +/** + * Adapts a toolbox SDK tool to the ADK {@link BaseTool} contract. + * + * The SDK ships its own `BaseTool` subclass, but it extends the `BaseTool` + * of whichever `@google/adk` copy the SDK resolved, which is not necessarily + * this one. Wrapping keeps `getTools()` returning tools branded by this + * package. + */ +class ToolboxTool extends BaseTool { + constructor(private readonly sdkTool: ToolboxSdkTool) { + super({ + name: sdkTool.getCoreTool().toolName, + description: sdkTool.getCoreTool().description, + }); + } + + override _getDeclaration(): FunctionDeclaration | undefined { + return this.sdkTool._getDeclaration(); + } + + override async runAsync(request: RunAsyncToolRequest): Promise { + return this.sdkTool.getCoreTool()(request.args); + } +} + +/** + * A toolset that exposes the tools served by an MCP Toolbox for Databases + * server. + * + * The toolset is a thin adapter over the `@toolbox-sdk/adk` package, which + * must be installed alongside `@google/adk` (it is declared as an optional + * peer dependency). Tools are re-listed on every {@link getTools} call, so a + * server-side change is picked up without recreating the toolset. + * + * Usage: + * ```ts + * import {LlmAgent, ToolboxToolset} from '@google/adk'; + * + * const toolbox = new ToolboxToolset('http://127.0.0.1:5000'); + * const agent = new LlmAgent({ + * name: 'hotel_agent', + * model: 'gemini-2.0-flash', + * tools: [toolbox], + * }); + * ``` + */ +export class ToolboxToolset extends BaseToolset { + private client?: ToolboxSdkClient; + + /** + * @param serverUrl The base URL of the toolbox server, used verbatim. + * @param options Selection, auth, binding and header options. + */ + constructor( + private readonly serverUrl: string, + private readonly options: ToolboxToolsetOptions = {}, + ) { + super([]); + } + + /** + * Returns the memoised toolbox client, creating it on first use. + * + * @throws If the optional `@toolbox-sdk/adk` peer is not installed. + */ + private async getClient(): Promise { + if (!this.client) { + let sdk: typeof import('@toolbox-sdk/adk'); + try { + sdk = await import('@toolbox-sdk/adk'); + } catch (cause) { + throw new Error( + `ToolboxToolset requires the '${TOOLBOX_SDK_PACKAGE}' package. ` + + `Install it with \`npm install ${TOOLBOX_SDK_PACKAGE}\`.`, + {cause}, + ); + } + this.client = new sdk.ToolboxClient( + this.serverUrl, + null, + this.options.additionalHeaders, + ); + } + return this.client; + } + + /** + * Loads the selected tools from the toolbox server. + * + * Tool selection happens server-side, so `context` is accepted for + * interface compatibility and ignored. + * + * @param _context Unused; selection is driven by the constructor options. + * @return The named toolset's tools followed by the individually named + * tools. + */ + override async getTools(_context?: ReadonlyContext): Promise { + const client = await this.getClient(); + const {toolsetName, toolNames, authTokenGetters, boundParams} = + this.options; + const sdkTools: ToolboxSdkTool[] = []; + + if (toolsetName !== undefined || !toolNames?.length) { + sdkTools.push( + ...(await client.loadToolset( + toolsetName, + authTokenGetters, + boundParams, + )), + ); + } + if (toolNames?.length) { + sdkTools.push( + ...(await Promise.all( + toolNames.map((name) => + client.loadTool(name, authTokenGetters, boundParams), + ), + )), + ); + } + return sdkTools.map((sdkTool) => new ToolboxTool(sdkTool)); + } + + /** + * Closes the toolset. + * + * The toolbox client holds no releasable resource, so this resolves + * immediately. + */ + override async close(): Promise {} +} From 82890de2fdc853c9cfd5a2b0b26596f2956f3178 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 11:07:45 -0700 Subject: [PATCH 3/7] test(core): cover ToolboxToolset selection, forwarding and failure paths Mocks @toolbox-sdk/adk end to end: the real package imports @google/adk at module scope, which resolves to the built core/dist and is absent on an unbuilt checkout. Covers both selectors and their union, identity-forwarding of auth token getters and bound params (asserting adk-js never resolves them itself), client laziness and memoisation, declaration passthrough, unwrapped propagation of server errors, and the missing-optional-peer message. --- core/test/tools/toolbox_toolset_test.ts | 350 ++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 core/test/tools/toolbox_toolset_test.ts diff --git a/core/test/tools/toolbox_toolset_test.ts b/core/test/tools/toolbox_toolset_test.ts new file mode 100644 index 000000000..46796a8c4 --- /dev/null +++ b/core/test/tools/toolbox_toolset_test.ts @@ -0,0 +1,350 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import { + Context, + createSession, + InvocationContext, + isBaseTool, + isBaseToolset, + PluginManager, + ReadonlyContext, + SequentialAgent, + ToolboxAuthTokenGetter, + ToolboxBoundValue, + ToolboxToolset, +} from '@google/adk'; +import {FunctionDeclaration, Type} from '@google/genai'; + +const SERVER_URL = 'http://127.0.0.1:5000'; + +/** + * The subset of the `@toolbox-sdk/adk` tool surface that `ToolboxToolset` + * actually touches. The real SDK is never loaded: it imports `@google/adk` at + * module scope, which resolves to the built `core/dist` and is absent on an + * unbuilt checkout. + */ +interface FakeSdkTool { + getCoreTool(): FakeCoreTool; + _getDeclaration(): FunctionDeclaration | undefined; +} + +type FakeCoreTool = ReturnType; + +/** + * Builds the callable `@toolbox-sdk/core` tool object: a function carrying the + * server-provided name and description as properties. + */ +function createFakeCoreTool(toolName: string) { + return Object.assign( + vi.fn(async (args?: Record): Promise => { + return `${toolName}:${JSON.stringify(args ?? {})}`; + }), + {toolName, description: `Description of ${toolName}`}, + ); +} + +function createFakeSdkTool( + toolName: string, + declaration: FunctionDeclaration | undefined = {name: toolName}, +): FakeSdkTool { + const coreTool = createFakeCoreTool(toolName); + return { + getCoreTool: () => coreTool, + _getDeclaration: () => declaration, + }; +} + +const {clientConstructor, loadTool, loadToolset, MockToolboxClient} = + vi.hoisted(() => { + const clientConstructor = + vi.fn< + ( + url: string, + session: unknown, + clientHeaders: Record | undefined, + ) => void + >(); + const loadToolset = + vi.fn< + ( + name?: string, + authTokenGetters?: Record, + boundParams?: Record, + ) => Promise + >(); + const loadTool = + vi.fn< + ( + name: string, + authTokenGetters?: Record, + boundParams?: Record, + ) => Promise + >(); + + class MockToolboxClient { + readonly loadToolset = loadToolset; + readonly loadTool = loadTool; + + constructor( + url: string, + session: unknown, + clientHeaders: Record | undefined, + ) { + clientConstructor(url, session, clientHeaders); + } + } + + return {clientConstructor, loadTool, loadToolset, MockToolboxClient}; + }); + +vi.mock('@toolbox-sdk/adk', () => ({ToolboxClient: MockToolboxClient})); + +/** Builds a real `Context` backed by real ADK plumbing (no stubs). */ +function createRealContext(): Context { + return new Context({invocationContext: createRealInvocationContext()}); +} + +function createRealInvocationContext(): InvocationContext { + return new InvocationContext({ + invocationId: 'test-invocation', + agent: new SequentialAgent({name: 'toolbox_test_agent'}), + session: createSession({id: 'test-session', appName: 'test-app'}), + pluginManager: new PluginManager([]), + }); +} + +describe('ToolboxToolset', () => { + beforeEach(() => { + vi.clearAllMocks(); + loadToolset.mockResolvedValue([]); + }); + + it('loads every tool on the server when no selector is given', async () => { + loadToolset.mockResolvedValue([ + createFakeSdkTool('search-hotels-by-name'), + createFakeSdkTool('book-hotel'), + ]); + + const toolset = new ToolboxToolset(SERVER_URL); + const tools = await toolset.getTools(); + + expect(isBaseToolset(toolset)).toBe(true); + expect(loadToolset).toHaveBeenCalledTimes(1); + expect(loadToolset).toHaveBeenCalledWith(undefined, undefined, undefined); + expect(loadTool).not.toHaveBeenCalled(); + expect(tools.map((tool) => tool.name)).toEqual([ + 'search-hotels-by-name', + 'book-hotel', + ]); + expect(tools.map((tool) => tool.description)).toEqual([ + 'Description of search-hotels-by-name', + 'Description of book-hotel', + ]); + expect(tools.map((tool) => isBaseTool(tool))).toEqual([true, true]); + }); + + it('loads the toolset named by toolsetName', async () => { + loadToolset.mockResolvedValue([createFakeSdkTool('search-hotels-by-name')]); + + const toolset = new ToolboxToolset(SERVER_URL, { + toolsetName: 'my-toolset', + }); + const tools = await toolset.getTools(); + + expect(loadToolset).toHaveBeenCalledWith( + 'my-toolset', + undefined, + undefined, + ); + expect(loadTool).not.toHaveBeenCalled(); + expect(tools.map((tool) => tool.name)).toEqual(['search-hotels-by-name']); + }); + + it('loads individually named tools without listing the whole server', async () => { + loadTool.mockImplementation(async (name) => createFakeSdkTool(name)); + + const toolset = new ToolboxToolset(SERVER_URL, {toolNames: ['a', 'b']}); + const tools = await toolset.getTools(); + + expect(loadToolset).not.toHaveBeenCalled(); + expect(loadTool).toHaveBeenCalledTimes(2); + expect(loadTool).toHaveBeenNthCalledWith(1, 'a', undefined, undefined); + expect(loadTool).toHaveBeenNthCalledWith(2, 'b', undefined, undefined); + expect(tools.map((tool) => tool.name)).toEqual(['a', 'b']); + }); + + it('unions toolsetName with toolNames, toolset tools first', async () => { + loadToolset.mockResolvedValue([createFakeSdkTool('from-toolset')]); + loadTool.mockImplementation(async (name) => createFakeSdkTool(name)); + + const toolset = new ToolboxToolset(SERVER_URL, { + toolsetName: 'my-toolset', + toolNames: ['named-tool'], + }); + const tools = await toolset.getTools(); + + expect(loadToolset).toHaveBeenCalledTimes(1); + expect(loadTool).toHaveBeenCalledTimes(1); + expect(tools.map((tool) => tool.name)).toEqual([ + 'from-toolset', + 'named-tool', + ]); + }); + + it('forwards auth token getters without ever resolving them', async () => { + const getToken = vi.fn(() => 'id-token'); + const authTokenGetters = {'my-google-auth': getToken}; + loadToolset.mockResolvedValue([createFakeSdkTool('from-toolset')]); + loadTool.mockImplementation(async (name) => createFakeSdkTool(name)); + + const toolset = new ToolboxToolset(SERVER_URL, { + toolsetName: 'my-toolset', + toolNames: ['named-tool'], + authTokenGetters, + }); + await toolset.getTools(); + + expect(loadToolset.mock.calls[0][1]).toBe(authTokenGetters); + expect(loadTool.mock.calls[0][1]).toBe(authTokenGetters); + expect(getToken).not.toHaveBeenCalled(); + }); + + it('forwards literal and callable bound params without resolving them', async () => { + const getUserId = vi.fn(() => 'user-1'); + const boundParams = {userId: getUserId, tenant: 'acme'}; + const declaration: FunctionDeclaration = { + name: 'search-hotels-by-name', + description: 'Search hotels', + parameters: { + type: Type.OBJECT, + properties: {name: {type: Type.STRING}}, + }, + }; + loadToolset.mockResolvedValue([ + createFakeSdkTool('search-hotels-by-name', declaration), + ]); + + const toolset = new ToolboxToolset(SERVER_URL, {boundParams}); + const [tool] = await toolset.getTools(); + + expect(loadToolset.mock.calls[0][2]).toBe(boundParams); + expect(getUserId).not.toHaveBeenCalled(); + expect(tool._getDeclaration()).toBe(declaration); + }); + + it('constructs the client with the server url and additional headers', async () => { + const additionalHeaders = {'X-Request-Source': 'adk-js'}; + + const toolset = new ToolboxToolset(SERVER_URL, {additionalHeaders}); + await toolset.getTools(); + + expect(clientConstructor).toHaveBeenCalledWith( + SERVER_URL, + null, + additionalHeaders, + ); + }); + + it('creates the client lazily and reuses it across getTools calls', async () => { + const toolset = new ToolboxToolset(SERVER_URL); + expect(clientConstructor).not.toHaveBeenCalled(); + + await toolset.getTools(); + await toolset.getTools(); + + expect(clientConstructor).toHaveBeenCalledTimes(1); + expect(loadToolset).toHaveBeenCalledTimes(2); + }); + + it('routes runAsync to the core callable and returns its result', async () => { + const sdkTool = createFakeSdkTool('search-hotels-by-name'); + loadToolset.mockResolvedValue([sdkTool]); + + const toolset = new ToolboxToolset(SERVER_URL); + const [tool] = await toolset.getTools(); + const args = {name: 'Hilton'}; + const result = await tool.runAsync({ + args, + toolContext: createRealContext(), + }); + + expect(sdkTool.getCoreTool()).toHaveBeenCalledTimes(1); + expect(sdkTool.getCoreTool()).toHaveBeenCalledWith(args); + expect(result).toBe('search-hotels-by-name:{"name":"Hilton"}'); + }); + + it('surfaces an undefined declaration unchanged', async () => { + const coreTool = createFakeCoreTool('no-declaration'); + const sdkTool: FakeSdkTool = { + getCoreTool: () => coreTool, + _getDeclaration: () => undefined, + }; + loadToolset.mockResolvedValue([sdkTool]); + + const toolset = new ToolboxToolset(SERVER_URL); + const [tool] = await toolset.getTools(); + + expect(tool._getDeclaration()).toBeUndefined(); + }); + + it('closes cleanly before and after tools have been loaded', async () => { + const toolset = new ToolboxToolset(SERVER_URL); + + await expect(toolset.close()).resolves.toBeUndefined(); + await toolset.getTools(); + await expect(toolset.close()).resolves.toBeUndefined(); + }); + + it('accepts and ignores a ReadonlyContext', async () => { + loadToolset.mockResolvedValue([createFakeSdkTool('search-hotels-by-name')]); + + const toolset = new ToolboxToolset(SERVER_URL); + const withoutContext = await toolset.getTools(); + const withContext = await toolset.getTools( + new ReadonlyContext(createRealInvocationContext()), + ); + + expect(withContext.map((tool) => tool.name)).toEqual( + withoutContext.map((tool) => tool.name), + ); + }); + + it('propagates server failures from the SDK unwrapped', async () => { + const failure = new Error('toolbox server returned 503'); + loadToolset.mockRejectedValue(failure); + + const toolset = new ToolboxToolset(SERVER_URL); + + await expect(toolset.getTools()).rejects.toBe(failure); + }); +}); + +describe('ToolboxToolset without the optional @toolbox-sdk/adk peer', () => { + it('reports the missing package and attaches the import failure', async () => { + vi.resetModules(); + const importFailure = new Error("Cannot find module '@toolbox-sdk/adk'"); + vi.doMock('@toolbox-sdk/adk', () => { + throw importFailure; + }); + + const {ToolboxToolset: FreshToolboxToolset} = await import('@google/adk'); + const toolset = new FreshToolboxToolset(SERVER_URL); + + await expect(toolset.getTools()).rejects.toThrow( + "ToolboxToolset requires the '@toolbox-sdk/adk' package. " + + 'Install it with `npm install @toolbox-sdk/adk`.', + ); + // The import failure is preserved as `cause`. Vitest interposes its own + // error when a mock factory throws, so the original sits one level deeper. + await expect(toolset.getTools()).rejects.toHaveProperty( + 'cause.cause', + importFailure, + ); + }); +}); From 1ebfdf1fd61a6e4c0057306d10ce30f20c7dc0d5 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 11:16:02 -0700 Subject: [PATCH 4/7] refactor(core): inline the toolbox package name in the missing-peer error The constant duplicated the import specifier, which must stay a literal for static analysis, so the two could drift apart. --- core/src/tools/toolbox_toolset.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/core/src/tools/toolbox_toolset.ts b/core/src/tools/toolbox_toolset.ts index 51925730e..d87e3fe35 100644 --- a/core/src/tools/toolbox_toolset.ts +++ b/core/src/tools/toolbox_toolset.ts @@ -15,9 +15,6 @@ import {ReadonlyContext} from '../agents/readonly_context.js'; import {BaseTool, RunAsyncToolRequest} from './base_tool.js'; import {BaseToolset} from './base_toolset.js'; -/** The npm package that backs {@link ToolboxToolset}. */ -const TOOLBOX_SDK_PACKAGE = '@toolbox-sdk/adk'; - /** * Produces an authentication token for a single named auth source. * @@ -143,8 +140,8 @@ export class ToolboxToolset extends BaseToolset { sdk = await import('@toolbox-sdk/adk'); } catch (cause) { throw new Error( - `ToolboxToolset requires the '${TOOLBOX_SDK_PACKAGE}' package. ` + - `Install it with \`npm install ${TOOLBOX_SDK_PACKAGE}\`.`, + "ToolboxToolset requires the '@toolbox-sdk/adk' package. " + + 'Install it with `npm install @toolbox-sdk/adk`.', {cause}, ); } From d0a05419b345c5f8ef11ed94222661879e663a67 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 11:21:19 -0700 Subject: [PATCH 5/7] test(core): pin that an empty toolNames array loads every tool The union guards read toolNames?.length, so [] behaves like omission. No other test distinguishes that from an === undefined check, which would silently return no tools at all. --- core/test/tools/toolbox_toolset_test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/test/tools/toolbox_toolset_test.ts b/core/test/tools/toolbox_toolset_test.ts index 46796a8c4..f85b18fa1 100644 --- a/core/test/tools/toolbox_toolset_test.ts +++ b/core/test/tools/toolbox_toolset_test.ts @@ -179,6 +179,17 @@ describe('ToolboxToolset', () => { expect(tools.map((tool) => tool.name)).toEqual(['a', 'b']); }); + it('treats an empty toolNames array as no selector at all', async () => { + loadToolset.mockResolvedValue([createFakeSdkTool('search-hotels-by-name')]); + + const toolset = new ToolboxToolset(SERVER_URL, {toolNames: []}); + const tools = await toolset.getTools(); + + expect(loadToolset).toHaveBeenCalledWith(undefined, undefined, undefined); + expect(loadTool).not.toHaveBeenCalled(); + expect(tools.map((tool) => tool.name)).toEqual(['search-hotels-by-name']); + }); + it('unions toolsetName with toolNames, toolset tools first', async () => { loadToolset.mockResolvedValue([createFakeSdkTool('from-toolset')]); loadTool.mockImplementation(async (name) => createFakeSdkTool(name)); From c78d2aa5faf04f16f99a3a2eee6e4cd344df0454 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 12:50:00 -0700 Subject: [PATCH 6/7] fix(core): stop @google/adk self-resolving to core's build output `@toolbox-sdk/adk`'s declarations import `@google/adk`, which the workspace symlink resolves to core/dist/types. tsc then sees that directory as both an input and an output of the same run and refuses to emit, so `npm run build` failed with 151 TS5055 errors on every build after the first. CI never saw it because a fresh checkout has no dist. Mapping the specifier to the sources fixes it for core's own compilation. The mapping cannot live in the root tsconfig: dev/ genuinely depends on the built core, and redirecting it there fails with TS6059 rootDir violations. --- core/tsconfig.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/tsconfig.json b/core/tsconfig.json index 1a037183c..cf39a893b 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -2,7 +2,12 @@ "extends": "../tsconfig.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist/types" + "outDir": "dist/types", + // `@toolbox-sdk/adk`'s declarations import `@google/adk`, which the + // workspace symlink resolves to this package's own build output. tsc then + // sees dist/types as both an input and an output and refuses to emit + // (TS5055) on every build after the first. Point it at the sources. + "paths": {"@google/adk": ["./src/index.ts"]} }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] From a3a2ec58666f362b95e9992797555f2cc646278c Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 12:50:02 -0700 Subject: [PATCH 7/7] refactor(core): return the SDK's tools from getTools instead of re-wrapping With the resolution bug fixed, the SDK's ToolboxTool is the same BaseTool this package declares, so the local adapter was pure indirection: the SDK already passes the core tool's name and description to super(), its runAsync is the same delegation, and its _getDeclaration does the zod conversion we wanted. The brand is Symbol.for(), a global registry, so isBaseTool() holds across duplicate package copies, and nothing on the getTools() consumption path uses instanceof (llm_agent.ts uses the symbol guard). Test fixtures now extend BaseTool, as the real SDK tools do; all 15 cases are kept. Also drops ToolboxBoundValue, which collapsed to exactly `unknown` (a union containing the top type absorbs its other arms), and moves the export off common.ts -- the browser barrel -- onto the node entrypoint beside MCPToolset, since the SDK is node-only. --- core/src/common.ts | 6 -- core/src/index.ts | 5 ++ core/src/tools/toolbox_toolset.ts | 47 +++------------ core/test/tools/toolbox_toolset_test.ts | 76 ++++++++++++------------- 4 files changed, 50 insertions(+), 84 deletions(-) diff --git a/core/src/common.ts b/core/src/common.ts index e41cfeda7..23f628165 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -269,12 +269,6 @@ export { } from './tools/preload_memory_tool.js'; export {requestInputTool} from './tools/request_input_tool.js'; export {ToolConfirmation} from './tools/tool_confirmation.js'; -export {ToolboxToolset} from './tools/toolbox_toolset.js'; -export type { - ToolboxAuthTokenGetter, - ToolboxBoundValue, - ToolboxToolsetOptions, -} from './tools/toolbox_toolset.js'; export {URL_CONTEXT, UrlContextTool} from './tools/url_context_tool.js'; export {VertexAiSearchTool} from './tools/vertex_ai_search_tool.js'; export type { diff --git a/core/src/index.ts b/core/src/index.ts index 242f18fca..af2417d00 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -55,6 +55,11 @@ export { RunSkillInlineScriptTool, } from './tools/skill/run_skill_inline_script_tool.js'; export {RunSkillScriptTool} from './tools/skill/run_skill_script_tool.js'; +export {ToolboxToolset} from './tools/toolbox_toolset.js'; +export type { + ToolboxAuthTokenGetter, + ToolboxToolsetOptions, +} from './tools/toolbox_toolset.js'; export * from './integrations/agent_registry/agent_registry.js'; export * from './telemetry/google_cloud.js'; diff --git a/core/src/tools/toolbox_toolset.ts b/core/src/tools/toolbox_toolset.ts index d87e3fe35..15035588c 100644 --- a/core/src/tools/toolbox_toolset.ts +++ b/core/src/tools/toolbox_toolset.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {FunctionDeclaration} from '@google/genai'; import type { ToolboxClient as ToolboxSdkClient, ToolboxTool as ToolboxSdkTool, @@ -12,7 +11,7 @@ import type { import {ReadonlyContext} from '../agents/readonly_context.js'; -import {BaseTool, RunAsyncToolRequest} from './base_tool.js'; +import {BaseTool} from './base_tool.js'; import {BaseToolset} from './base_toolset.js'; /** @@ -23,15 +22,6 @@ import {BaseToolset} from './base_toolset.js'; */ export type ToolboxAuthTokenGetter = () => string | Promise; -/** - * A value bound to a tool parameter: either a literal, or a callable that - * produces one when the tool is invoked. - */ -export type ToolboxBoundValue = - | unknown - | (() => unknown) - | (() => Promise); - /** * Options for {@link ToolboxToolset}. * @@ -59,40 +49,16 @@ export interface ToolboxToolsetOptions { /** * Maps a tool parameter name to a value that is pre-filled on every call - * and hidden from the model. See + * and hidden from the model. A value is either a literal or a callable, + * sync or async, that the toolbox SDK resolves on each call. See * https://github.com/googleapis/mcp-toolbox-sdk-js/tree/main/packages/toolbox-core#binding-parameter-values */ - boundParams?: Record; + boundParams?: Record; /** Static headers sent with every request to the toolbox server. */ additionalHeaders?: Record; } -/** - * Adapts a toolbox SDK tool to the ADK {@link BaseTool} contract. - * - * The SDK ships its own `BaseTool` subclass, but it extends the `BaseTool` - * of whichever `@google/adk` copy the SDK resolved, which is not necessarily - * this one. Wrapping keeps `getTools()` returning tools branded by this - * package. - */ -class ToolboxTool extends BaseTool { - constructor(private readonly sdkTool: ToolboxSdkTool) { - super({ - name: sdkTool.getCoreTool().toolName, - description: sdkTool.getCoreTool().description, - }); - } - - override _getDeclaration(): FunctionDeclaration | undefined { - return this.sdkTool._getDeclaration(); - } - - override async runAsync(request: RunAsyncToolRequest): Promise { - return this.sdkTool.getCoreTool()(request.args); - } -} - /** * A toolset that exposes the tools served by an MCP Toolbox for Databases * server. @@ -162,7 +128,8 @@ export class ToolboxToolset extends BaseToolset { * * @param _context Unused; selection is driven by the constructor options. * @return The named toolset's tools followed by the individually named - * tools. + * tools. The SDK returns `BaseTool`s already, so they are passed + * through unwrapped. */ override async getTools(_context?: ReadonlyContext): Promise { const client = await this.getClient(); @@ -188,7 +155,7 @@ export class ToolboxToolset extends BaseToolset { )), ); } - return sdkTools.map((sdkTool) => new ToolboxTool(sdkTool)); + return sdkTools; } /** diff --git a/core/test/tools/toolbox_toolset_test.ts b/core/test/tools/toolbox_toolset_test.ts index f85b18fa1..95aa07dc6 100644 --- a/core/test/tools/toolbox_toolset_test.ts +++ b/core/test/tools/toolbox_toolset_test.ts @@ -7,6 +7,7 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; import { + BaseTool, Context, createSession, InvocationContext, @@ -14,9 +15,9 @@ import { isBaseToolset, PluginManager, ReadonlyContext, + RunAsyncToolRequest, SequentialAgent, ToolboxAuthTokenGetter, - ToolboxBoundValue, ToolboxToolset, } from '@google/adk'; import {FunctionDeclaration, Type} from '@google/genai'; @@ -24,40 +25,40 @@ import {FunctionDeclaration, Type} from '@google/genai'; const SERVER_URL = 'http://127.0.0.1:5000'; /** - * The subset of the `@toolbox-sdk/adk` tool surface that `ToolboxToolset` - * actually touches. The real SDK is never loaded: it imports `@google/adk` at - * module scope, which resolves to the built `core/dist` and is absent on an - * unbuilt checkout. + * Stands in for `@toolbox-sdk/adk`'s `ToolboxTool`, which is itself a + * `BaseTool` wrapping a callable `@toolbox-sdk/core` tool. The real SDK is + * never loaded: it imports `@google/adk` at module scope, which resolves to + * the built `core/dist` and is absent on an unbuilt checkout. */ -interface FakeSdkTool { - getCoreTool(): FakeCoreTool; - _getDeclaration(): FunctionDeclaration | undefined; -} +class FakeSdkTool extends BaseTool { + /** The callable core tool, exposed so tests can assert how it was invoked. */ + readonly coreTool = vi.fn( + async (args?: Record): Promise => { + return `${this.name}:${JSON.stringify(args ?? {})}`; + }, + ); -type FakeCoreTool = ReturnType; + constructor( + name: string, + private readonly declaration: FunctionDeclaration | undefined, + ) { + super({name, description: `Description of ${name}`}); + } -/** - * Builds the callable `@toolbox-sdk/core` tool object: a function carrying the - * server-provided name and description as properties. - */ -function createFakeCoreTool(toolName: string) { - return Object.assign( - vi.fn(async (args?: Record): Promise => { - return `${toolName}:${JSON.stringify(args ?? {})}`; - }), - {toolName, description: `Description of ${toolName}`}, - ); + override _getDeclaration(): FunctionDeclaration | undefined { + return this.declaration; + } + + override async runAsync(request: RunAsyncToolRequest): Promise { + return this.coreTool(request.args); + } } function createFakeSdkTool( toolName: string, declaration: FunctionDeclaration | undefined = {name: toolName}, ): FakeSdkTool { - const coreTool = createFakeCoreTool(toolName); - return { - getCoreTool: () => coreTool, - _getDeclaration: () => declaration, - }; + return new FakeSdkTool(toolName, declaration); } const {clientConstructor, loadTool, loadToolset, MockToolboxClient} = @@ -75,7 +76,7 @@ const {clientConstructor, loadTool, loadToolset, MockToolboxClient} = ( name?: string, authTokenGetters?: Record, - boundParams?: Record, + boundParams?: Record, ) => Promise >(); const loadTool = @@ -83,7 +84,7 @@ const {clientConstructor, loadTool, loadToolset, MockToolboxClient} = ( name: string, authTokenGetters?: Record, - boundParams?: Record, + boundParams?: Record, ) => Promise >(); @@ -273,7 +274,7 @@ describe('ToolboxToolset', () => { expect(loadToolset).toHaveBeenCalledTimes(2); }); - it('routes runAsync to the core callable and returns its result', async () => { + it('returns the SDK tool unwrapped, so runAsync reaches the core callable', async () => { const sdkTool = createFakeSdkTool('search-hotels-by-name'); loadToolset.mockResolvedValue([sdkTool]); @@ -285,18 +286,17 @@ describe('ToolboxToolset', () => { toolContext: createRealContext(), }); - expect(sdkTool.getCoreTool()).toHaveBeenCalledTimes(1); - expect(sdkTool.getCoreTool()).toHaveBeenCalledWith(args); + expect(sdkTool.coreTool).toHaveBeenCalledTimes(1); + expect(sdkTool.coreTool).toHaveBeenCalledWith(args); expect(result).toBe('search-hotels-by-name:{"name":"Hilton"}'); }); - it('surfaces an undefined declaration unchanged', async () => { - const coreTool = createFakeCoreTool('no-declaration'); - const sdkTool: FakeSdkTool = { - getCoreTool: () => coreTool, - _getDeclaration: () => undefined, - }; - loadToolset.mockResolvedValue([sdkTool]); + it('returns the SDK tool unwrapped, so an absent declaration stays absent', async () => { + // Constructed directly: passing `undefined` to createFakeSdkTool would + // fall back to its default declaration. + loadToolset.mockResolvedValue([ + new FakeSdkTool('no-declaration', undefined), + ]); const toolset = new ToolboxToolset(SERVER_URL); const [tool] = await toolset.getTools();