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/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 new file mode 100644 index 000000000..15035588c --- /dev/null +++ b/core/src/tools/toolbox_toolset.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ToolboxClient as ToolboxSdkClient, + ToolboxTool as ToolboxSdkTool, +} from '@toolbox-sdk/adk'; + +import {ReadonlyContext} from '../agents/readonly_context.js'; + +import {BaseTool} from './base_tool.js'; +import {BaseToolset} from './base_toolset.js'; + +/** + * 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; + +/** + * 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. 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; + + /** Static headers sent with every request to the toolbox server. */ + additionalHeaders?: Record; +} + +/** + * 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/adk' package. " + + 'Install it with `npm install @toolbox-sdk/adk`.', + {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. The SDK returns `BaseTool`s already, so they are passed + * through unwrapped. + */ + 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; + } + + /** + * Closes the toolset. + * + * The toolbox client holds no releasable resource, so this resolves + * immediately. + */ + override async close(): Promise {} +} diff --git a/core/test/tools/toolbox_toolset_test.ts b/core/test/tools/toolbox_toolset_test.ts new file mode 100644 index 000000000..95aa07dc6 --- /dev/null +++ b/core/test/tools/toolbox_toolset_test.ts @@ -0,0 +1,361 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import { + BaseTool, + Context, + createSession, + InvocationContext, + isBaseTool, + isBaseToolset, + PluginManager, + ReadonlyContext, + RunAsyncToolRequest, + SequentialAgent, + ToolboxAuthTokenGetter, + ToolboxToolset, +} from '@google/adk'; +import {FunctionDeclaration, Type} from '@google/genai'; + +const SERVER_URL = 'http://127.0.0.1:5000'; + +/** + * 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. + */ +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 ?? {})}`; + }, + ); + + constructor( + name: string, + private readonly declaration: FunctionDeclaration | undefined, + ) { + super({name, description: `Description of ${name}`}); + } + + 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 { + return new FakeSdkTool(toolName, 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('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)); + + 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('returns the SDK tool unwrapped, so runAsync reaches the core callable', 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.coreTool).toHaveBeenCalledTimes(1); + expect(sdkTool.coreTool).toHaveBeenCalledWith(args); + expect(result).toBe('search-hotels-by-name:{"name":"Hilton"}'); + }); + + 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(); + + 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, + ); + }); +}); 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"] 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",