Skip to content

Feat: port ToolboxToolset from adk-python (MCP Toolbox for Databases) - #583

Open
AmaadMartin wants to merge 7 commits into
mainfrom
feat/toolbox-toolset
Open

Feat: port ToolboxToolset from adk-python (MCP Toolbox for Databases)#583
AmaadMartin wants to merge 7 commits into
mainfrom
feat/toolbox-toolset

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    Closes: #issue_number
    Related: #issue_number
  2. Or, if no issue exists, describe the change:

Problem: adk-python ships ToolboxToolset (src/google/adk/tools/toolbox_toolset.py), which lets an ADK agent consume the tools served by a running MCP Toolbox for Databases server. adk-js has no equivalent — Toolbox returns zero hits across core/src, dev/src and integrations/src. TypeScript users have no way to reach a Toolbox server short of hand-rolling a client.

Solution: Add ToolboxToolset, a BaseToolset exported from @google/adk.

The most important design fact is that the Python class is not a protocol implementation — it is a ~110-line delegate around the separately published toolbox-adk PyPI package. This port keeps that shape: a thin adapter over the first-party JavaScript SDK (@toolbox-sdk/adk, published by Google from googleapis/mcp-toolbox-sdk-js), not a hand-rolled Toolbox/MCP client.

Design decisions worth a reviewer's attention:

  • The SDK is an optional peer + dev dependency, never a regular dependency. @toolbox-sdk/adk itself depends on @google/adk, so making it a regular dependency of core creates a package cycle. It is loaded through a dynamic import() inside a try/catch, and its type imports are all import type in private positions — the emitted core/dist/types/tools/toolbox_toolset.d.ts contains no import of the SDK, so consumers who never install it see no dangling type reference. peerDependenciesMeta.optional is a new key in core/package.json; without it npm 7+ would auto-install the peer and drag the cycle into every consumer's tree. This mirrors Python's opt-in pip install google-adk[toolbox] extra.

  • We depend on @toolbox-sdk/adk, not @toolbox-sdk/core. @toolbox-sdk/core declares zod@^3.24.4 as a peer, which conflicts with this repo's zod@^4.2.1 and makes npm install fail with ERESOLVE. @toolbox-sdk/adk carries zod@3 as a regular dependency, so npm nests it (verified: node_modules/@toolbox-sdk/adk/node_modules/{zod@3.25.76,@toolbox-sdk/core} alongside the repo's zod@4.4.3).

  • Root-cause fix: @google/adk no longer self-resolves to core's own build output. @toolbox-sdk/adk's declarations import @google/adk, which the workspace symlink resolves to core/dist/types. tsc then treats that directory as both an input and an output of the same run and refuses to emit — npm run build failed with 151 TS5055 errors on every build after the first, while CI stayed green because a fresh checkout has no dist. core/tsconfig.json now maps the specifier to the sources.

    Measured: main builds twice cleanly, this branch (before the fix) did not, and now does — three consecutive npm run build runs, 0 TS5055. The mapping deliberately lives in core/tsconfig.json rather than the root: dev/ genuinely depends on the built core, and redirecting the specifier repo-wide fails dev's build with TS6059 rootDir violations (measured, so rejected).

  • getTools() returns the SDK's tools unwrapped. An earlier revision wrapped each tool in a local BaseTool adapter, because the unique symbol brand on BaseTool made the SDK's tools non-assignable (TS2322). That was a symptom of the resolution bug above; with the mapping in place it disappears, and the adapter was pure indirection — the SDK's ToolboxTool already extends BaseTool, already passes the core tool's name and description to super(), its runAsync is the same delegation, and its _getDeclaration() performs the zod conversion. It is now deleted.

    Runtime safety was checked independently rather than assumed: the brand is Symbol.for('google.adk.baseTool'), a global registry, so isBaseTool() holds even across duplicate package copies; and nothing on the getTools() consumption path uses instanceof (llm_agent.ts:322 uses the symbol guard, and skill_toolset.ts's instanceof applies to user-declared tools, not toolset results).

  • The declaration and the execution both stay inside the SDK. adk-js never converts the tool's schema — it is a zod v3 object and this repo's zod-v4 helpers would mishandle it — and never invokes the core callable itself. Returning the tools unwrapped is what guarantees that.

  • Auth getters and bound params are forwarded by identity, never resolved here. That is what makes Python's documented "resolved per call, not captured once" semantics hold; the tests assert both that the objects are passed by reference and that adk-js never invokes the callables.

  • Exported from the node entrypoint, not the browser barrel. core/src/index_web.ts is only export * from './common.js', so anything in common.ts is on the browser surface. @toolbox-sdk/adk declares engines: {node: ">=20"} and pulls axios and @modelcontextprotocol/sdk, so the export lives in core/src/index.ts beside MCPToolset, matching how every other node-only export in this package is placed.

Cross-language parity notes (which rule was applied where the two conflict):

Python Here Rule applied
server_url positional; toolset_name/tool_names/… keyword args serverUrl positional + a ToolboxToolsetOptions bag Local TS convention wins — these never leave the process.
Both selectors are a union; omitting both loads everything same Parity wins — observable behaviour.
get_tools(readonly_context) ignores the context (selection is server-side) getTools(_context) accepts and ignores Parity.
close() forwards to the delegate no-op The JS ToolboxClient exposes only loadTool/loadToolset — there is no close/dispose and nothing to release. Precedent: AgentRegistrySingleMCPToolset.close() is likewise empty.
ImportError("ToolboxToolset requires the 'toolbox-adk' package. …") Error("ToolboxToolset requires the '@toolbox-sdk/adk' package. …", {cause}) Parity on the message shape; cause preserves the underlying import failure.
credentials: Optional[CredentialConfig] omitted Known parity gap. The published JS ToolboxClient constructor is (url, session?, clientHeaders?, protocol?) — there is no credentials parameter and no CredentialConfig type in its public typings. Rather than invent a stub option with no reader, it is left out.
**kwargs passthrough omitted Not idiomatic in TypeScript.

Collision check (run before starting): gh pr list --repo AmaadMartin/adk-js --state all --limit 1000 returned zero PRs — open or closed — whose title or branch mentions toolbox. The adjacent toolset PRs that do exist (#528 GCS, #529 ApiRegistry, #533/#534 Eventarc, #464/#465 APIHub, #462/#463 Google API Discovery, #578/#579 Application Integration, #104#106 BigQuery/Bigtable/PubSub, and the MCP PRs #112/#141/#147/#580) each add their own files; none touches core/src/tools/toolbox_toolset.ts. Branched from main, not stacked.

Known limitation, disclosed rather than hidden. The repo-level npm run ts:check (root tsconfig.json, not part of the CI workflow) reports 281 pre-existing errors on main, all of one class: @google/adk resolves to core/dist/types, so the unique symbol brand on BaseTool/BaseAgent mismatches the one in core/src. This PR makes it 282 — one further instance of that same pre-existing defect, at return sdkTools. Fixing it properly means putting the path mapping at the root, which breaks dev's build as described above, so it is out of scope here. npm run build, npm run lint, npm run format:check and npm run docs:check — the checks CI actually runs — are all clean.

No unrequested extras: no toolFilter/prefix option (Python has neither — super([])), no @experimental decorator (MCPToolset does not use it), and no docs pages or samples.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

core/test/tools/toolbox_toolset_test.ts — 15 tests, @toolbox-sdk/adk fully mocked. Mocking is mandatory rather than convenient: the real package imports @google/adk at module scope, which inside the workspace resolves to core/dist/esm and is absent on an unbuilt checkout. The fixtures extend BaseTool, exactly as the real SDK's tools do.

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

npx vitest run --project unit:core core/test/tools/          # surrounding area
  Test Files  41 passed (41)     Tests  425 passed (425)

Coverage of the new file is 100% statements / 100% branches / 100% functions / 100% lines (--coverage.include='core/src/tools/toolbox_toolset.ts'). No coverage suppression was added and no structure was weakened to reach it.

Proof that the tests can fail (mutation testing). Coverage is a floor, not proof, so every test was run against deliberately broken source. 17 mutations were applied one at a time to core/src/tools/toolbox_toolset.ts; every one was killed, and each of the 15 tests is killed by at least one:

# Mutation Test(s) killed
R1 Drop the || !toolNames?.length arm of the selector guard 7 tests, incl. loads every tool…
R2 loadToolset(toolsetName, …)loadToolset(undefined, …) loads the toolset named by toolsetName
R3 Selector guard → if (true) loads individually named tools…
R4 Guard → toolNames === undefined (treat [] as an explicit selector) treats an empty toolNames array as no selector at all
R5 if (toolNames?.length)if (false) 3 tests, incl. unions toolsetName with toolNames
R6 authTokenGetters{...authTokenGetters} (copy, not identity) 4 tests, incl. forwards auth token getters…
R7 boundParams{...boundParams} 4 tests, incl. forwards literal and callable bound params…
R8 Client built with undefined instead of additionalHeaders constructs the client with the server url and additional headers
R9 if (!this.client)if (true) (drop memoisation) creates the client lazily and reuses it
R10 Load the named tools before the toolset's tools unions…, loads individually named tools…
R11 close() throws closes cleanly before and after tools have been loaded
R12 getTools returns [] when _context is set accepts and ignores a ReadonlyContext
R13 .catch(() => {throw new Error('re-tagged')}) around loadToolset propagates server failures from the SDK unwrapped
R14 Drop {cause} from the missing-peer error reports the missing package and attaches the import failure
R15 Delete the try/catch so the raw import error escapes reports the missing package and attaches the import failure
R16 Re-introduce a lossy wrapper (ignores args, drops the declaration) returns the SDK tool unwrapped, so runAsync reaches the core callable; forwards literal and callable bound params…
R17 Wrapper that synthesizes a declaration when the SDK tool has none returns the SDK tool unwrapped, so an absent declaration stays absent

R16 and R17 exist specifically because deleting the adapter changed what two of the tests pin: they now guard the passthrough invariant (adk-js must not wrap, rename, or intercept the SDK's tools) rather than adapter behaviour, and they are renamed accordingly. Without those two mutations they would only have been killed by the coarse R1. The source was diffed against a pristine copy afterwards to confirm no mutation was left behind.

Failure paths are exercised, not just the happy path: the missing-optional-peer error (message and the preserved cause), and unwrapped propagation of a server/transport rejection.

Manual End-to-End (E2E) Tests:

No automated e2e test is added, and this is deliberate rather than an omission: an e2e test would import the real @toolbox-sdk/adk, which requires both a built core/dist and a live MCP Toolbox server — neither is available to CI. Nothing was added under tests/integration/** or tests/e2e/**.

Instead the feature was verified locally against the real SDK with no mocks anywhere: a throwaway stateless-MCP Toolbox server (plain node:http, JSON-RPC tools/list + tools/call on /mcp/[toolset]) was run on loopback, the freshly built @google/adk and the real @toolbox-sdk/adk were installed into a scratch package, and ToolboxToolset was driven against it over real HTTP. All ten checks passed — before and after the adapter was removed, which is the evidence that removing it is behaviour-preserving:

PASS  default selection loads all server tools
PASS  declaration is delegated to the SDK (zod v3 schema)
PASS  toolsetName and toolNames union, toolset first
PASS  bound param is hidden from the declaration
PASS  runAsync reaches the server with bound params filled in
PASS  bound callable is resolved once per call, not captured once
PASS  additionalHeaders reach the server
PASS  auth token getter is invoked per call and reaches the server
PASS  server failures propagate from the SDK
PASS  close resolves
ALL E2E CHECKS PASSED

Notably this confirms the two behaviours the mocked tests can only assert by delegation: a bound parameter really is pre-filled on the wire and hidden from the FunctionDeclaration, and an auth-token getter really is invoked once per call (id-token-1, then id-token-2) and arrives as the my-google-auth_token header.

For a maintainer who wants to reproduce against a real server: run a local MCP Toolbox server on 127.0.0.1:5000, npm install @toolbox-sdk/adk, and confirm await new ToolboxToolset('http://127.0.0.1:5000').getTools() returns the server's tools.

Full local validation on the pushed commit:

npm install                    # clean, no ERESOLVE; nests zod@3 under @toolbox-sdk/adk
npm run build                  # ok -- and now idempotent (3 consecutive runs, 0 TS5055)
npm run lint                   # ok
npm run format:check           # ok
npm run docs:check             # ok (typedoc --treatWarningsAsErrors)
bash scripts/check_license.sh  # ok
npx secretlint <new files>     # ok
npx tsc --noEmit -p core/tsconfig.json   # ok, re-run AFTER the build

Type-leak check on the emitted declarations: grep -rE "from '@toolbox-sdk|import\(.@toolbox-sdk" core/dist/types/ returns no files. Export-placement check: core/dist/types/index_web.d.ts and core/dist/web/index_web.js contain zero Toolbox references, while core/dist/types/index.d.ts carries the export.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

Amaad Martin added 7 commits August 3, 2026 10:54
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.
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.
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.
…rror

The constant duplicated the import specifier, which must stay a literal for
static analysis, so the two could drift apart.
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.
`@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.
…apping

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant