From c01d60dde009db0ec0aa09ea34cf283457b3e86a Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 09:12:35 -0700 Subject: [PATCH 1/5] Feat: port ApplicationIntegrationToolset and IntegrationConnectorTool Turns an Application Integration trigger, or an Integration Connector connection's entity operations and actions, into ADK tools. The connector arguments (connection name, service name, host, entity, operation, action) are stripped from the model-facing declaration and re-applied after the model's arguments are read, so a model cannot redirect a call. Caller-supplied auth only reaches the connector when the connection enables auth overrides. Because a TypeScript constructor cannot await, the network I/O adk-python performs in __init__ runs on the first getTools() call, memoised so concurrent callers share one initialization. --- core/src/common.ts | 4 + .../application_integration_toolset.ts | 342 ++++++++++ .../integration_connector_tool.ts | 183 ++++++ .../openapi_spec_parser/tool_auth_handler.ts | 5 +- ...on_integration_toolset_integration_test.ts | 170 +++++ .../application_integration_toolset_test.ts | 620 ++++++++++++++++++ .../integration_connector_tool_test.ts | 327 +++++++++ 7 files changed, 1650 insertions(+), 1 deletion(-) create mode 100644 core/src/tools/application_integration_tool/application_integration_toolset.ts create mode 100644 core/src/tools/application_integration_tool/integration_connector_tool.ts create mode 100644 core/test/tools/application_integration_tool/application_integration_toolset_integration_test.ts create mode 100644 core/test/tools/application_integration_tool/application_integration_toolset_test.ts create mode 100644 core/test/tools/application_integration_tool/integration_connector_tool_test.ts diff --git a/core/src/common.ts b/core/src/common.ts index 64b302c40..7ab2201f0 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -309,7 +309,11 @@ export * from './artifacts/base_artifact_service.js'; export * from './features/feature_registry.js'; export * from './memory/base_memory_service.js'; export * from './sessions/base_session_service.js'; +export {ApplicationIntegrationToolset} from './tools/application_integration_tool/application_integration_toolset.js'; +export type {ApplicationIntegrationToolsetOptions} from './tools/application_integration_tool/application_integration_toolset.js'; export type {EntityOperations} from './tools/application_integration_tool/clients/integration_client.js'; +export {IntegrationConnectorTool} from './tools/application_integration_tool/integration_connector_tool.js'; +export type {IntegrationConnectorToolOptions} from './tools/application_integration_tool/integration_connector_tool.js'; export * from './tools/base_tool.js'; export {OpenApiSpecParser} from './tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.js'; export type { diff --git a/core/src/tools/application_integration_tool/application_integration_toolset.ts b/core/src/tools/application_integration_tool/application_integration_toolset.ts new file mode 100644 index 000000000..71ab75084 --- /dev/null +++ b/core/src/tools/application_integration_tool/application_integration_toolset.ts @@ -0,0 +1,342 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; +import {ReadonlyContext} from '../../agents/readonly_context.js'; +import { + AuthCredential, + AuthCredentialTypes, +} from '../../auth/auth_credential.js'; +import {AuthScheme} from '../../auth/auth_schemes.js'; +import {AuthConfig} from '../../auth/auth_tool.js'; +import {experimental} from '../../utils/experimental.js'; +import {logger} from '../../utils/logger.js'; +import {parseServiceAccountCredential} from '../../utils/service_account_utils.js'; +import {BaseTool} from '../base_tool.js'; +import {BaseToolset, ToolPredicate} from '../base_toolset.js'; +import {OpenApiSpecParser} from '../openapi_tool/openapi_spec_parser/openapi_spec_parser.js'; +import {DEFAULT_CREDENTIAL_KEY} from '../openapi_tool/openapi_spec_parser/tool_auth_handler.js'; +import {OpenAPIToolset} from '../openapi_tool/openapi_toolset.js'; +import {createRestApiTool} from '../openapi_tool/rest_api_tool.js'; +import { + ConnectionDetails, + ConnectionsClient, +} from './clients/connections_client.js'; +import { + EntityOperations, + IntegrationClient, +} from './clients/integration_client.js'; +import {IntegrationConnectorTool} from './integration_connector_tool.js'; + +const CLOUD_PLATFORM_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', +]; + +const INVALID_REQUEST_MESSAGE = + 'Invalid request, Either integration or (connection and (entity_operations' + + ' or actions)) should be provided.'; + +/** Extension keys the connector spec adds to each generated operation. */ +interface ConnectorOperationExtensions { + 'x-operation'?: string; + 'x-entity'?: string; + 'x-action'?: string; +} + +type ConnectorOperationObject = OpenAPIV3.OperationObject & + ConnectorOperationExtensions; + +/** Constructor options for {@link ApplicationIntegrationToolset}. */ +export interface ApplicationIntegrationToolsetOptions { + /** The Google Cloud project ID. */ + project: string; + /** The Google Cloud location, e.g. `us-central1`. */ + location: string; + /** Overrides the default `ExecuteConnection` integration name. */ + connectionTemplateOverride?: string; + /** The integration name. */ + integration?: string; + /** The trigger IDs to publish from the integration. */ + triggers?: string[]; + /** The connection name. */ + connection?: string; + /** The entity operations to publish from the connection. */ + entityOperations?: EntityOperations; + /** The actions to publish from the connection. */ + actions?: string[]; + /** Prepended to every generated tool name. */ + toolNamePrefix?: string; + /** Appended to every generated tool description. */ + toolInstructions?: string; + /** + * A service account key file. Required when Application Default Credentials + * are not available or should not be used. + */ + serviceAccountJson?: string; + authScheme?: AuthScheme; + authCredential?: AuthCredential; + /** Tool predicate, or the names of the tools to expose. */ + toolFilter?: ToolPredicate | string[]; + credentialKey?: string; +} + +/** + * Generates tools from an Application Integration or Integration Connector + * resource. + * + * Unlike adk-python, whose constructor performs the HTTP calls, the network I/O + * runs on the first `getTools()` call because a TypeScript constructor cannot + * await. The constructor still validates the requested mode and throws + * synchronously. + * + * @example + * ```ts + * // Publish an integration's API triggers as tools. + * const toolset = new ApplicationIntegrationToolset({ + * project: 'test-project', + * location: 'us-central1', + * integration: 'test-integration', + * triggers: ['api_trigger/test_trigger'], + * }); + * + * // Publish a connection's entity operations and actions as tools. See + * // https://cloud.google.com/integration-connectors/docs/reference/rest/v1/projects.locations.connections.connectionSchemaMetadata + * // for the operations and actions a connection supports. + * const connectorToolset = new ApplicationIntegrationToolset({ + * project: 'test-project', + * location: 'us-central1', + * connection: 'test-connection', + * entityOperations: {Issues: ['LIST', 'GET'], Projects: []}, + * actions: ['ExecuteCustomQuery'], + * }); + * ``` + */ +@experimental +export class ApplicationIntegrationToolset extends BaseToolset { + readonly project: string; + readonly location: string; + + /** + * The auth configuration the connector tools were built with, if any. + * + * Set `exchangedAuthCredential` on it before calling `getTools()` to hand the + * connector tools an exchanged credential; the cached tools are left + * untouched and clones carrying the credential are returned instead. + */ + readonly authConfig?: AuthConfig; + + private readonly options: ApplicationIntegrationToolsetOptions; + private readonly connection: string; + private readonly tools: IntegrationConnectorTool[] = []; + private openapiToolset?: OpenAPIToolset; + private initPromise?: Promise; + + constructor(options: ApplicationIntegrationToolsetOptions) { + super(options.toolFilter || []); + if (!isValidMode(options)) { + throw new Error(INVALID_REQUEST_MESSAGE); + } + + this.options = options; + this.connection = options.connection ?? ''; + this.project = options.project; + this.location = options.location; + this.authConfig = options.authScheme + ? { + authScheme: options.authScheme, + rawAuthCredential: options.authCredential, + credentialKey: options.credentialKey ?? DEFAULT_CREDENTIAL_KEY, + } + : undefined; + } + + @experimental + override async getTools(context?: ReadonlyContext): Promise { + await this.initialize(); + + if (this.openapiToolset) { + return this.openapiToolset.getTools(context); + } + + const selected = this.tools.filter((tool) => { + if (Array.isArray(this.toolFilter) && this.toolFilter.length > 0) { + return this.toolFilter.includes(tool.name); + } + if (context) { + return this.isToolSelected(tool, context); + } + return true; + }); + + const exchanged = this.authConfig?.exchangedAuthCredential; + if (!exchanged) { + return selected; + } + return selected.map((tool) => + tool.authScheme ? cloneWithAuthCredential(tool, exchanged) : tool, + ); + } + + @experimental + override async close(): Promise { + await this.openapiToolset?.close(); + } + + /** + * Fetches the spec and builds the tools once, no matter how many callers race + * on the first `getTools()`. + */ + private initialize(): Promise { + this.initPromise ??= this.fetchAndBuildTools(); + return this.initPromise; + } + + private async fetchAndBuildTools(): Promise { + const {project, location, serviceAccountJson} = this.options; + const integrationClient = new IntegrationClient({ + project, + location, + connectionTemplateOverride: this.options.connectionTemplateOverride, + integration: this.options.integration, + triggers: this.options.triggers, + connection: this.options.connection, + entityOperations: this.options.entityOperations, + actions: this.options.actions, + serviceAccountJson, + }); + const {authScheme, authCredential} = + buildSpecCredentials(serviceAccountJson); + + if (this.options.integration) { + this.openapiToolset = new OpenAPIToolset({ + specDict: await integrationClient.getOpenApiSpecForIntegration(), + authScheme, + authCredential, + credentialKey: this.options.credentialKey, + toolFilter: this.toolFilter, + }); + return; + } + + const connectionDetails = await new ConnectionsClient({ + project, + location, + connection: this.connection, + serviceAccountJson, + }).getConnectionDetails(); + const spec = await integrationClient.getOpenApiSpecForConnection( + this.options.toolNamePrefix ?? '', + this.options.toolInstructions ?? '', + ); + + for (const parsed of new OpenApiSpecParser().parse(spec)) { + const operation: ConnectorOperationObject = parsed.operation; + const restApiTool = createRestApiTool(parsed); + restApiTool.configureAuthScheme(authScheme); + restApiTool.configureAuthCredential(authCredential); + + this.tools.push( + new IntegrationConnectorTool({ + name: restApiTool.name, + description: restApiTool.description, + connectionName: connectionDetails.name, + connectionHost: connectionDetails.host, + connectionServiceName: connectionDetails.serviceName, + entity: operation['x-entity'] ?? '', + action: operation['x-entity'] ? '' : (operation['x-action'] ?? ''), + operation: operation['x-operation'] ?? '', + restApiTool, + ...this.connectorAuth(connectionDetails), + credentialKey: this.options.credentialKey, + }), + ); + } + } + + /** + * Caller-supplied auth only reaches the connector when the connection allows + * it to be overridden. + */ + private connectorAuth(connectionDetails: ConnectionDetails): { + authScheme?: AuthScheme; + authCredential?: AuthCredential; + } { + const {authScheme, authCredential} = this.options; + if ( + authScheme && + authCredential && + !connectionDetails.authOverrideEnabled + ) { + logger.warn( + 'Authentication schema and credentials are not used because' + + ' authOverrideEnabled is not enabled in the connection.', + ); + return {}; + } + return {authScheme, authCredential}; + } +} + +function isValidMode(options: ApplicationIntegrationToolsetOptions): boolean { + if (options.integration) { + return true; + } + const hasEntityOperations = + Object.keys(options.entityOperations ?? {}).length > 0; + return Boolean( + options.connection && (hasEntityOperations || options.actions?.length), + ); +} + +/** + * Credentials used to fetch the generated spec's endpoints: an explicit service + * account when one is configured, Application Default Credentials otherwise. + */ +function buildSpecCredentials(serviceAccountJson?: string): { + authScheme: OpenAPIV3.HttpSecurityScheme; + authCredential: AuthCredential; +} { + const authScheme: OpenAPIV3.HttpSecurityScheme = { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }; + const serviceAccount = serviceAccountJson + ? { + serviceAccountCredential: + parseServiceAccountCredential(serviceAccountJson), + scopes: CLOUD_PLATFORM_SCOPES, + } + : {useDefaultCredential: true, scopes: CLOUD_PLATFORM_SCOPES}; + + return { + authScheme, + authCredential: { + authType: AuthCredentialTypes.SERVICE_ACCOUNT, + serviceAccount, + }, + }; +} + +function cloneWithAuthCredential( + tool: IntegrationConnectorTool, + authCredential: AuthCredential, +): IntegrationConnectorTool { + return new IntegrationConnectorTool({ + name: tool.name, + description: tool.description, + connectionName: tool.connectionName, + connectionHost: tool.connectionHost, + connectionServiceName: tool.connectionServiceName, + entity: tool.entity, + operation: tool.operation, + action: tool.action, + restApiTool: tool.restApiTool, + authScheme: tool.authScheme, + authCredential, + credentialKey: tool.credentialKey, + }); +} diff --git a/core/src/tools/application_integration_tool/integration_connector_tool.ts b/core/src/tools/application_integration_tool/integration_connector_tool.ts new file mode 100644 index 000000000..db1449644 --- /dev/null +++ b/core/src/tools/application_integration_tool/integration_connector_tool.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FunctionDeclaration, Schema} from '@google/genai'; +import {AuthCredential} from '../../auth/auth_credential.js'; +import {AuthScheme} from '../../auth/auth_schemes.js'; +import {experimental} from '../../utils/experimental.js'; +import {logger} from '../../utils/logger.js'; +import {BaseTool, RunAsyncToolRequest} from '../base_tool.js'; +import {ToolAuthHandler} from '../openapi_tool/openapi_spec_parser/tool_auth_handler.js'; +import {RestApiTool} from '../openapi_tool/rest_api_tool.js'; + +/** Constructor options for {@link IntegrationConnectorTool}. */ +export interface IntegrationConnectorToolOptions { + /** Tool name, derived from the generated API operation. */ + name: string; + /** Tool description, derived from the generated API operation. */ + description: string; + /** Resource name of the Integration Connector connection. */ + connectionName: string; + /** Hostname of the connection, empty unless it uses a TLS service. */ + connectionHost: string; + /** Service directory backing the connection. */ + connectionServiceName: string; + /** Entity the operation targets, empty for action operations. */ + entity: string; + /** Connector operation to run, e.g. `LIST_ENTITIES`. */ + operation: string; + /** Action the operation runs, empty for entity operations. */ + action: string; + /** Tool performing the underlying `:execute` call. */ + restApiTool: RestApiTool; + authScheme?: AuthScheme; + authCredential?: AuthCredential; + credentialKey?: string; +} + +/** + * Wraps a {@link RestApiTool} with the Application Integration context — + * connection details, entity, operation and action — needed to run one + * connector operation. + * + * The connector plumbing is hidden from the model: it is stripped from the + * function declaration and always written by {@link IntegrationConnectorTool.runAsync} + * after the model's arguments, so a model cannot redirect the call. + */ +@experimental +export class IntegrationConnectorTool extends BaseTool { + /** Arguments supplied by the toolset, never by the model. */ + static readonly EXCLUDE_FIELDS = [ + 'connection_name', + 'service_name', + 'host', + 'entity', + 'operation', + 'action', + 'dynamic_auth_config', + ]; + + /** + * Arguments the model may supply but never has to. `sortByColumns` is not + * snake_cased, matching the reference implementation and the name the + * connector accepts. + */ + static readonly OPTIONAL_FIELDS = [ + 'page_size', + 'page_token', + 'filter', + 'sortByColumns', + ]; + + readonly connectionName: string; + readonly connectionHost: string; + readonly connectionServiceName: string; + readonly entity: string; + readonly operation: string; + readonly action: string; + readonly restApiTool: RestApiTool; + readonly authScheme?: AuthScheme; + readonly authCredential?: AuthCredential; + readonly credentialKey?: string; + + constructor(options: IntegrationConnectorToolOptions) { + super({name: options.name, description: options.description}); + this.connectionName = options.connectionName; + this.connectionHost = options.connectionHost; + this.connectionServiceName = options.connectionServiceName; + this.entity = options.entity; + this.operation = options.operation; + this.action = options.action; + this.restApiTool = options.restApiTool; + this.authScheme = options.authScheme; + this.authCredential = options.authCredential; + this.credentialKey = options.credentialKey; + } + + @experimental + override _getDeclaration(): FunctionDeclaration { + return { + name: this.name, + description: this.description, + parameters: filterConnectorParameters( + this.restApiTool._getDeclaration()?.parameters, + ), + }; + } + + @experimental + override async runAsync(request: RunAsyncToolRequest): Promise { + const authHandler = ToolAuthHandler.fromToolContext( + request.toolContext, + this.authScheme, + this.authCredential, + {credentialKey: this.credentialKey}, + ); + const authResult = await authHandler.prepareAuthCredentials(); + if (authResult.state === 'pending') { + return { + pending: true, + message: 'Needs your authorization to access your data.', + }; + } + + const args: Record = {...request.args}; + if (authResult.authCredential) { + args['dynamic_auth_config'] = { + 'oauth2_auth_code_flow.access_token': + authResult.authCredential.http?.credentials?.token ?? {}, + }; + } + args['connection_name'] = this.connectionName; + args['service_name'] = this.connectionServiceName; + args['host'] = this.connectionHost; + args['entity'] = this.entity; + args['operation'] = this.operation; + args['action'] = this.action; + + // Argument values can carry an access token, so only the keys are logged. + logger.debug( + `Running tool: ${this.name} with args: ${Object.keys(args).join(', ')}`, + ); + return this.restApiTool.runAsync({args, toolContext: request.toolContext}); + } +} + +/** + * Hides the connector plumbing from the model: drops every excluded field from + * the properties, and every excluded or optional field from the required list. + * + * @param parameters The schema generated for the underlying REST tool. + * @returns A new schema; the argument is left untouched. + */ +export function filterConnectorParameters( + parameters: Schema | undefined, +): Schema | undefined { + if (!parameters) { + return undefined; + } + + const excluded = new Set(IntegrationConnectorTool.EXCLUDE_FIELDS); + const notRequired = new Set([ + ...IntegrationConnectorTool.EXCLUDE_FIELDS, + ...IntegrationConnectorTool.OPTIONAL_FIELDS, + ]); + + const filtered: Schema = {...parameters}; + if (parameters.properties) { + filtered.properties = Object.fromEntries( + Object.entries(parameters.properties).filter( + ([name]) => !excluded.has(name), + ), + ); + } + if (parameters.required) { + filtered.required = parameters.required.filter( + (name) => !notRequired.has(name), + ); + } + return filtered; +} diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts index dc2650a75..2f6f7060e 100644 --- a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts +++ b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts @@ -11,6 +11,9 @@ import {AuthConfig} from '../../../auth/auth_tool.js'; import {experimental} from '../../../utils/experimental.js'; import {AutoAuthCredentialExchanger} from '../auth/credential_exchangers/auto_auth_credential_exchanger.js'; +/** Credential key used when a tool does not configure one. */ +export const DEFAULT_CREDENTIAL_KEY = 'default_openapi_key'; + export interface AuthPreparationResult { state: 'pending' | 'done'; authCredential?: AuthCredential; @@ -83,7 +86,7 @@ export class ToolAuthHandler { const authConfig: AuthConfig = { authScheme: this.authScheme, rawAuthCredential: this.authCredential, - credentialKey: this.credentialKey || 'default_openapi_key', + credentialKey: this.credentialKey || DEFAULT_CREDENTIAL_KEY, }; // A credential returned by an auth response was supplied interactively by diff --git a/core/test/tools/application_integration_tool/application_integration_toolset_integration_test.ts b/core/test/tools/application_integration_tool/application_integration_toolset_integration_test.ts new file mode 100644 index 000000000..7d59856f2 --- /dev/null +++ b/core/test/tools/application_integration_tool/application_integration_toolset_integration_test.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ApplicationIntegrationToolset, + Context, + createSession, + IntegrationConnectorTool, + InvocationContext, + LlmAgent, + PluginManager, +} from '@google/adk'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + MockInstance, + vi, +} from 'vitest'; + +const PROJECT = 'test-project'; +const LOCATION = 'us-central1'; +const CONNECTION = 'test-connection'; +const CONNECTOR_HOST = 'https://connectors.googleapis.com'; +const EXECUTE_URL_PREFIX = `https://integrations.googleapis.com/v2/projects/${PROJECT}/locations/${LOCATION}/integrations/ExecuteConnection:execute`; + +const ENTITY_SCHEMA = { + type: 'object', + properties: {summary: {type: ['null', 'string']}}, +}; + +const {googleAuthCtor} = vi.hoisted(() => ({googleAuthCtor: vi.fn()})); + +vi.mock('google-auth-library', () => ({ + GoogleAuth: googleAuthCtor, + JWT: vi.fn(), +})); + +/** Answers the Integration Connectors and Application Integration APIs. */ +function routeRequest(url: string): Response { + if (url.startsWith(`${CONNECTOR_HOST}/v1/projects`)) { + if (url.includes('connectionSchemaMetadata:getEntityType')) { + return Response.json({name: 'operations/test_op'}); + } + return Response.json({ + name: `projects/${PROJECT}/locations/${LOCATION}/connections/${CONNECTION}`, + serviceDirectory: 'test-service', + tlsServiceDirectory: 'tls-test-service', + host: 'test.host', + }); + } + if (url === `${CONNECTOR_HOST}/v1/operations/test_op`) { + return Response.json({ + done: true, + response: {jsonSchema: ENTITY_SCHEMA, operations: ['LIST', 'GET']}, + }); + } + if (url.startsWith(EXECUTE_URL_PREFIX)) { + return Response.json({connectorOutputPayload: [{summary: 'an issue'}]}); + } + return new Response(`unexpected request: ${url}`, {status: 404}); +} + +function createToolContext(): Context { + return new Context({ + invocationContext: new InvocationContext({ + invocationId: 'test-invocation', + agent: new LlmAgent({name: 'test_agent'}), + session: createSession({id: 'test-session', appName: 'test-app'}), + pluginManager: new PluginManager([]), + }), + functionCallId: 'test-function-call', + }); +} + +describe('ApplicationIntegrationToolset connector chain', () => { + let fetchMock: MockInstance; + + beforeEach(() => { + googleAuthCtor.mockImplementation(() => ({ + getClient: async () => ({ + getAccessToken: async () => ({token: 'test_token'}), + quotaProjectId: undefined, + }), + })); + fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input) => routeRequest(String(input))); + }); + + afterEach(() => { + vi.restoreAllMocks(); + googleAuthCtor.mockReset(); + }); + + function createToolset(): ApplicationIntegrationToolset { + return new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: CONNECTION, + entityOperations: {Issues: ['LIST']}, + toolNamePrefix: 'jira', + toolInstructions: 'Use this to manage Jira issues.', + }); + } + + it('builds one connector tool from the generated spec', async () => { + const tools = await createToolset().getTools(); + + expect(tools).toHaveLength(1); + // adk-js snake-cases the generated `jira_list_Issues` operation ID, which + // doubles the separator before the entity name. + expect(tools[0].name).toBe('jira_list__issues'); + expect(tools[0]).toBeInstanceOf(IntegrationConnectorTool); + expect(tools[0]).toMatchObject({ + entity: 'Issues', + operation: 'LIST_ENTITIES', + connectionName: `projects/${PROJECT}/locations/${LOCATION}/connections/${CONNECTION}`, + connectionServiceName: 'tls-test-service', + connectionHost: 'test.host', + }); + }); + + it('hides every connector argument from the generated declaration', async () => { + const [tool] = await createToolset().getTools(); + + const parameters = tool._getDeclaration()?.parameters; + + expect(Object.keys(parameters?.properties ?? {})).toEqual([ + 'filter_clause', + 'page_size', + 'page_token', + 'sort_by_columns', + ]); + expect(parameters?.required).toEqual([]); + }); + + it('executes the connector operation against the integration endpoint', async () => { + const [tool] = await createToolset().getTools(); + + const result = await tool.runAsync({ + args: {page_size: 10}, + toolContext: createToolContext(), + }); + + expect(result).toEqual({connectorOutputPayload: [{summary: 'an issue'}]}); + const [url, init] = fetchMock.mock.lastCall ?? []; + const requestUrl = new URL(String(url)); + expect(`${requestUrl.origin}${requestUrl.pathname}`).toBe( + EXECUTE_URL_PREFIX, + ); + expect(requestUrl.searchParams.get('triggerId')).toBe( + 'api_trigger/ExecuteConnection#list_Issues', + ); + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ + connectionName: `projects/${PROJECT}/locations/${LOCATION}/connections/${CONNECTION}`, + serviceName: 'tls-test-service', + host: 'test.host', + entity: 'Issues', + operation: 'LIST_ENTITIES', + pageSize: 10, + }); + }); +}); diff --git a/core/test/tools/application_integration_tool/application_integration_toolset_test.ts b/core/test/tools/application_integration_tool/application_integration_toolset_test.ts new file mode 100644 index 000000000..531f5eed6 --- /dev/null +++ b/core/test/tools/application_integration_tool/application_integration_toolset_test.ts @@ -0,0 +1,620 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ApplicationIntegrationToolset, + AuthCredential, + AuthCredentialTypes, + IntegrationConnectorTool, + InvocationContext, + LlmAgent, + OpenAPIToolset, + PluginManager, + ReadonlyContext, + RestApiTool, + createSession, +} from '@google/adk'; +import {OpenAPIV3} from 'openapi-types'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {logger} from '../../../src/utils/logger.js'; + +const PROJECT = 'test-project'; +const LOCATION = 'us-central1'; + +const INVALID_REQUEST_MESSAGE = + 'Invalid request, Either integration or (connection and (entity_operations' + + ' or actions)) should be provided.'; + +const OAUTH2_SCHEME: OpenAPIV3.OAuth2SecurityScheme = { + type: 'oauth2', + flows: { + authorizationCode: { + authorizationUrl: 'https://test-url/o/oauth2/auth', + tokenUrl: 'https://test-url/token', + scopes: {'https://test-url/auth/test-scope': 'test scope'}, + }, + }, +}; + +const RAW_CREDENTIAL: AuthCredential = { + authType: AuthCredentialTypes.OAUTH2, + oauth2: {clientId: 'test-client-id', clientSecret: 'test-client-secret'}, +}; + +const SERVICE_ACCOUNT_JSON = JSON.stringify({ + 'type': 'service_account', + 'project_id': 'dummy', + 'private_key_id': 'dummy-key-id', + 'private_key': 'dummy-private-key', + 'client_email': 'test@example.com', + 'client_id': '131331543646416', + 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', + 'token_uri': 'https://oauth2.googleapis.com/token', + 'auth_provider_x509_cert_url': 'https://www.googleapis.com/oauth2/v1/certs', + 'client_x509_cert_url': + 'http://www.googleapis.com/robot/v1/metadata/x509/dummy%40dummy.com', + 'universe_domain': 'googleapis.com', +}); + +const { + integrationClient, + integrationClientCtor, + connectionsClient, + connectionsClientCtor, +} = vi.hoisted(() => { + const integrationClient = { + getOpenApiSpecForIntegration: vi.fn(), + getOpenApiSpecForConnection: vi.fn(), + }; + const connectionsClient = {getConnectionDetails: vi.fn()}; + return { + integrationClient, + integrationClientCtor: vi.fn(() => integrationClient), + connectionsClient, + connectionsClientCtor: vi.fn(() => connectionsClient), + }; +}); + +vi.mock( + '../../../src/tools/application_integration_tool/clients/integration_client.js', + () => ({IntegrationClient: integrationClientCtor}), +); + +vi.mock( + '../../../src/tools/application_integration_tool/clients/connections_client.js', + () => ({ConnectionsClient: connectionsClientCtor}), +); + +/** Minimal spec shaped like the one Application Integration generates. */ +function specWithOperations( + operations: Array>, +): OpenAPIV3.Document { + const paths: OpenAPIV3.PathsObject = {}; + for (const [index, operation] of operations.entries()) { + paths[`/v2/execute#${index}`] = { + post: { + responses: {'200': {description: 'Success response'}}, + ...operation, + }, + }; + } + return { + openapi: '3.0.1', + info: {title: 'ExecuteConnection', version: '4'}, + servers: [{url: 'https://integrations.googleapis.com'}], + paths, + }; +} + +function readonlyContext(): ReadonlyContext { + return new ReadonlyContext( + new InvocationContext({ + invocationId: 'test-invocation', + agent: new LlmAgent({name: 'test_agent'}), + session: createSession({id: 'test-session', appName: 'test-app'}), + pluginManager: new PluginManager([]), + }), + ); +} + +describe('ApplicationIntegrationToolset', () => { + beforeEach(() => { + integrationClient.getOpenApiSpecForIntegration.mockResolvedValue( + specWithOperations([{operationId: 'test_tool'}]), + ); + integrationClient.getOpenApiSpecForConnection.mockResolvedValue( + specWithOperations([ + { + operationId: 'list_issues', + 'x-entity': 'Issues', + 'x-operation': 'LIST_ENTITIES', + }, + ]), + ); + connectionsClient.getConnectionDetails.mockResolvedValue({ + name: 'test-connection', + serviceName: 'test-service', + host: 'test.host', + authOverrideEnabled: false, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + integrationClientCtor.mockClear(); + connectionsClientCtor.mockClear(); + integrationClient.getOpenApiSpecForIntegration.mockReset(); + integrationClient.getOpenApiSpecForConnection.mockReset(); + connectionsClient.getConnectionDetails.mockReset(); + }); + + describe('integration mode', () => { + it('publishes the integration spec as REST tools', async () => { + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + integration: 'test-integration', + triggers: ['test-trigger'], + }); + + const tools = await toolset.getTools(); + + expect(tools.map((tool) => tool.name)).toEqual(['test_tool']); + expect(integrationClientCtor).toHaveBeenCalledWith({ + project: PROJECT, + location: LOCATION, + connectionTemplateOverride: undefined, + integration: 'test-integration', + triggers: ['test-trigger'], + connection: undefined, + entityOperations: undefined, + actions: undefined, + serviceAccountJson: undefined, + }); + expect( + integrationClient.getOpenApiSpecForIntegration, + ).toHaveBeenCalledOnce(); + expect(connectionsClientCtor).not.toHaveBeenCalled(); + }); + + it('publishes one tool per trigger operation', async () => { + integrationClient.getOpenApiSpecForIntegration.mockResolvedValue( + specWithOperations([ + {operationId: 'test_tool'}, + {operationId: 'test_tool_2'}, + ]), + ); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + integration: 'test-integration', + triggers: ['test-trigger1', 'test-trigger2'], + }); + + const tools = await toolset.getTools(); + + expect(tools.map((tool) => tool.name)).toEqual([ + 'test_tool', + 'test_tool_2', + ]); + }); + + it('accepts an integration without triggers', async () => { + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + integration: 'test-integration', + }); + + await expect(toolset.getTools()).resolves.toHaveLength(1); + expect(connectionsClientCtor).not.toHaveBeenCalled(); + }); + + it('authenticates with an explicit service account when configured', async () => { + const configureAuthCredential = vi.spyOn( + RestApiTool.prototype, + 'configureAuthCredential', + ); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + integration: 'test-integration', + serviceAccountJson: SERVICE_ACCOUNT_JSON, + }); + + await toolset.getTools(); + + expect(configureAuthCredential).toHaveBeenCalledWith({ + authType: AuthCredentialTypes.SERVICE_ACCOUNT, + serviceAccount: { + serviceAccountCredential: expect.objectContaining({ + clientEmail: 'test@example.com', + privateKeyId: 'dummy-key-id', + clientX509CertUrl: + 'http://www.googleapis.com/robot/v1/metadata/x509/dummy%40dummy.com', + }), + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + }); + }); + + it('falls back to application default credentials', async () => { + const configureAuthCredential = vi.spyOn( + RestApiTool.prototype, + 'configureAuthCredential', + ); + const configureAuthScheme = vi.spyOn( + RestApiTool.prototype, + 'configureAuthScheme', + ); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + integration: 'test-integration', + }); + + await toolset.getTools(); + + expect(configureAuthCredential).toHaveBeenCalledWith({ + authType: AuthCredentialTypes.SERVICE_ACCOUNT, + serviceAccount: { + useDefaultCredential: true, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + }); + expect(configureAuthScheme).toHaveBeenCalledWith({ + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }); + }); + + it('closes the underlying OpenAPI toolset', async () => { + const close = vi.spyOn(OpenAPIToolset.prototype, 'close'); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + integration: 'test-integration', + }); + + await toolset.getTools(); + await toolset.close(); + + expect(close).toHaveBeenCalledOnce(); + }); + }); + + describe('connection mode', () => { + it('publishes entity operations as connector tools', async () => { + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + toolNamePrefix: 'My Connection Tool', + toolInstructions: 'Use this tool to manage entities.', + }); + + const tools = await toolset.getTools(); + + expect(tools).toHaveLength(1); + const [tool] = tools; + expect(tool).toBeInstanceOf(IntegrationConnectorTool); + expect(tool.name).toBe('list_issues'); + expect(tool).toMatchObject({ + entity: 'Issues', + operation: 'LIST_ENTITIES', + action: '', + connectionName: 'test-connection', + connectionHost: 'test.host', + connectionServiceName: 'test-service', + }); + expect(connectionsClientCtor).toHaveBeenCalledWith({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + serviceAccountJson: undefined, + }); + expect( + integrationClient.getOpenApiSpecForConnection, + ).toHaveBeenCalledWith( + 'My Connection Tool', + 'Use this tool to manage entities.', + ); + }); + + it('publishes actions as connector tools', async () => { + integrationClient.getOpenApiSpecForConnection.mockResolvedValue( + specWithOperations([ + { + operationId: 'list_issues_operation', + 'x-action': 'CustomAction', + 'x-operation': 'EXECUTE_ACTION', + }, + ]), + ); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + actions: ['CustomAction'], + }); + + const [tool] = await toolset.getTools(); + + expect(tool.name).toBe('list_issues_operation'); + expect(tool).toMatchObject({ + action: 'CustomAction', + operation: 'EXECUTE_ACTION', + entity: '', + }); + expect( + integrationClient.getOpenApiSpecForConnection, + ).toHaveBeenCalledWith('', ''); + }); + + it('prefers the entity over the action when both are present', async () => { + integrationClient.getOpenApiSpecForConnection.mockResolvedValue( + specWithOperations([ + { + operationId: 'list_issues', + 'x-entity': 'Issues', + 'x-action': 'CustomAction', + 'x-operation': 'LIST_ENTITIES', + }, + ]), + ); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + }); + + const [tool] = await toolset.getTools(); + + expect(tool).toMatchObject({entity: 'Issues', action: ''}); + }); + + it('leaves the entity and action empty when the spec names neither', async () => { + integrationClient.getOpenApiSpecForConnection.mockResolvedValue( + specWithOperations([ + {operationId: 'run_query', 'x-operation': 'EXECUTE_QUERY'}, + ]), + ); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + actions: ['ExecuteCustomQuery'], + }); + + const [tool] = await toolset.getTools(); + + expect(tool).toMatchObject({ + entity: '', + action: '', + operation: 'EXECUTE_QUERY', + }); + }); + + it('defaults a spec operation with no connector extensions', async () => { + integrationClient.getOpenApiSpecForConnection.mockResolvedValue( + specWithOperations([{operationId: 'bare_operation'}]), + ); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + actions: ['ExecuteCustomQuery'], + }); + + const [tool] = await toolset.getTools(); + + expect(tool).toMatchObject({entity: '', action: '', operation: ''}); + }); + + it('resolves close() without an OpenAPI toolset', async () => { + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + }); + + await toolset.getTools(); + + await expect(toolset.close()).resolves.toBeUndefined(); + }); + }); + + describe('mode validation', () => { + it.each([ + ['neither an integration nor a connection', {}], + ['triggers alone', {triggers: ['test']}], + ['a connection alone', {connection: 'test'}], + [ + 'a connection with no operations', + {connection: 'test', entityOperations: {}, actions: []}, + ], + ])('rejects %s', (_description, options) => { + expect( + () => + new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + ...options, + }), + ).toThrow(INVALID_REQUEST_MESSAGE); + }); + }); + + describe('connector authentication', () => { + it('hands the connector tools the caller auth when overrides are enabled', async () => { + connectionsClient.getConnectionDetails.mockResolvedValue({ + name: 'test-connection', + serviceName: 'test-service', + host: 'test.host', + authOverrideEnabled: true, + }); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + authScheme: OAUTH2_SCHEME, + authCredential: RAW_CREDENTIAL, + credentialKey: 'test-key', + }); + + const [tool] = await toolset.getTools(); + + expect(tool).toMatchObject({ + authScheme: OAUTH2_SCHEME, + authCredential: RAW_CREDENTIAL, + credentialKey: 'test-key', + }); + }); + + it('withholds the caller auth when overrides are disabled', async () => { + const warn = vi.spyOn(logger, 'warn'); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + authScheme: OAUTH2_SCHEME, + authCredential: RAW_CREDENTIAL, + }); + + const [tool] = await toolset.getTools(); + + expect(tool).toMatchObject({ + authScheme: undefined, + authCredential: undefined, + }); + expect(warn).toHaveBeenCalledWith( + 'Authentication schema and credentials are not used because' + + ' authOverrideEnabled is not enabled in the connection.', + ); + }); + + it('returns clones carrying the exchanged credential', async () => { + connectionsClient.getConnectionDetails.mockResolvedValue({ + name: 'test-connection', + serviceName: 'test-service', + host: 'test.host', + authOverrideEnabled: true, + }); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + authScheme: OAUTH2_SCHEME, + authCredential: RAW_CREDENTIAL, + }); + const [original] = await toolset.getTools(); + + const exchanged: AuthCredential = { + authType: AuthCredentialTypes.OAUTH2, + oauth2: {...RAW_CREDENTIAL.oauth2, accessToken: 'exchanged-token'}, + }; + if (!toolset.authConfig) { + expect.fail('the toolset should expose an auth config'); + } + toolset.authConfig.exchangedAuthCredential = exchanged; + const [resolved] = await toolset.getTools(); + + expect(resolved).not.toBe(original); + expect(resolved).toMatchObject({authCredential: exchanged}); + expect(original).toMatchObject({authCredential: RAW_CREDENTIAL}); + }); + + it('leaves tools without an auth scheme unexchanged', async () => { + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + authScheme: OAUTH2_SCHEME, + authCredential: RAW_CREDENTIAL, + }); + const [original] = await toolset.getTools(); + if (!toolset.authConfig) { + expect.fail('the toolset should expose an auth config'); + } + toolset.authConfig.exchangedAuthCredential = RAW_CREDENTIAL; + + const [resolved] = await toolset.getTools(); + + expect(resolved).toBe(original); + }); + + it('exposes no auth config when no scheme was supplied', () => { + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + }); + + expect(toolset.authConfig).toBeUndefined(); + }); + }); + + describe('tool filtering and initialization', () => { + it('selects connector tools by name', async () => { + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + toolFilter: ['other_tool'], + }); + + await expect(toolset.getTools()).resolves.toEqual([]); + }); + + it('applies a tool predicate when a context is supplied', async () => { + const toolFilter = vi.fn().mockReturnValue(false); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + toolFilter, + }); + const context = readonlyContext(); + + await expect(toolset.getTools(context)).resolves.toEqual([]); + expect(toolFilter).toHaveBeenCalledWith( + expect.objectContaining({name: 'list_issues'}), + context, + ); + }); + + it('fetches the spec once for concurrent callers', async () => { + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + }); + + const [first, second] = await Promise.all([ + toolset.getTools(), + toolset.getTools(), + ]); + + expect( + integrationClient.getOpenApiSpecForConnection, + ).toHaveBeenCalledOnce(); + expect(connectionsClient.getConnectionDetails).toHaveBeenCalledOnce(); + expect(first[0]).toBe(second[0]); + }); + }); +}); diff --git a/core/test/tools/application_integration_tool/integration_connector_tool_test.ts b/core/test/tools/application_integration_tool/integration_connector_tool_test.ts new file mode 100644 index 000000000..b92f36526 --- /dev/null +++ b/core/test/tools/application_integration_tool/integration_connector_tool_test.ts @@ -0,0 +1,327 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthCredential, + AuthCredentialTypes, + Context, + IntegrationConnectorTool, + IntegrationConnectorToolOptions, + InvocationContext, + LlmAgent, + PluginManager, + RestApiTool, + ToolAuthHandler, + createSession, +} from '@google/adk'; +import {OpenAPIV3} from 'openapi-types'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {filterConnectorParameters} from '../../../src/tools/application_integration_tool/integration_connector_tool.js'; + +const REST_TOOL_RESULT = {status: 'success', data: 'mock_data'}; + +const BEARER_SCHEME: OpenAPIV3.HttpSecurityScheme = { + type: 'http', + scheme: 'bearer', +}; + +const API_KEY_SCHEME: OpenAPIV3.ApiKeySecurityScheme = { + type: 'apiKey', + name: 'x-api-key', + in: 'header', +}; + +function bearerCredential(token?: string): AuthCredential { + return { + authType: AuthCredentialTypes.HTTP, + http: {scheme: 'bearer', credentials: token === undefined ? {} : {token}}, + }; +} + +function createToolContext(): Context { + return new Context({ + invocationContext: new InvocationContext({ + invocationId: 'test-invocation', + agent: new LlmAgent({name: 'test_agent'}), + session: createSession({id: 'test-session', appName: 'test-app'}), + pluginManager: new PluginManager([]), + }), + functionCallId: 'test-function-call', + }); +} + +function createRestTool(): RestApiTool { + const operation: OpenAPIV3.OperationObject = { + operationId: 'list_issues', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + user_id: {type: 'string', description: 'User ID'}, + connection_name: {type: 'string'}, + host: {type: 'string'}, + service_name: {type: 'string'}, + entity: {type: 'string'}, + operation: {type: 'string'}, + action: {type: 'string'}, + dynamic_auth_config: {type: 'object'}, + page_size: {type: 'integer'}, + filter: {type: 'string'}, + }, + required: ['user_id', 'page_size', 'filter', 'connection_name'], + }, + }, + }, + }, + responses: {}, + }; + return new RestApiTool( + 'mock_rest_tool', + 'Mock REST tool description.', + {baseUrl: 'https://test.host', path: '/v2/execute', method: 'post'}, + operation, + ); +} + +function createTool(overrides: Partial = {}): { + tool: IntegrationConnectorTool; + restApiTool: RestApiTool; +} { + const restApiTool = createRestTool(); + vi.spyOn(restApiTool, 'runAsync').mockResolvedValue(REST_TOOL_RESULT); + const tool = new IntegrationConnectorTool({ + name: 'test_integration_tool', + description: 'Test integration tool description.', + connectionName: 'test-conn', + connectionHost: 'test.example.com', + connectionServiceName: 'test-service', + entity: 'TestEntity', + operation: 'LIST', + action: 'TestAction', + restApiTool, + ...overrides, + }); + return {tool, restApiTool}; +} + +describe('IntegrationConnectorTool declaration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('hides the connector plumbing from the model', () => { + const {tool} = createTool(); + + const properties = tool._getDeclaration().parameters?.properties ?? {}; + + expect(Object.keys(properties)).toEqual(['user_id', 'page_size', 'filter']); + }); + + it('keeps only genuinely required arguments required', () => { + const {tool} = createTool(); + + expect(tool._getDeclaration().parameters?.required).toEqual(['user_id']); + }); + + it('names the declaration after the connector tool, not the REST tool', () => { + const {tool} = createTool(); + + const declaration = tool._getDeclaration(); + + expect(declaration.name).toBe('test_integration_tool'); + expect(declaration.description).toBe('Test integration tool description.'); + }); +}); + +describe('filterConnectorParameters', () => { + it('passes an absent schema through', () => { + expect(filterConnectorParameters(undefined)).toBeUndefined(); + }); + + it('handles a schema without properties or required', () => { + expect(filterConnectorParameters({title: 'list_issues_Arguments'})).toEqual( + {title: 'list_issues_Arguments'}, + ); + }); + + it('leaves the argument untouched', () => { + const parameters = { + properties: {user_id: {}, host: {}}, + required: ['user_id', 'host'], + }; + + filterConnectorParameters(parameters); + + expect(parameters).toEqual({ + properties: {user_id: {}, host: {}}, + required: ['user_id', 'host'], + }); + }); + + it('drops optional fields from required without hiding them', () => { + const filtered = filterConnectorParameters({ + properties: {page_size: {}, sortByColumns: {}}, + required: ['page_size', 'sortByColumns'], + }); + + expect(filtered).toEqual({ + properties: {page_size: {}, sortByColumns: {}}, + required: [], + }); + }); +}); + +describe('IntegrationConnectorTool.runAsync', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('adds the connection context and returns the REST tool result', async () => { + const {tool, restApiTool} = createTool(); + const toolContext = createToolContext(); + + const result = await tool.runAsync({ + args: {user_id: 'user123', page_size: 10}, + toolContext, + }); + + expect(result).toEqual(REST_TOOL_RESULT); + expect(restApiTool.runAsync).toHaveBeenCalledWith({ + args: { + user_id: 'user123', + page_size: 10, + connection_name: 'test-conn', + host: 'test.example.com', + service_name: 'test-service', + entity: 'TestEntity', + operation: 'LIST', + action: 'TestAction', + }, + toolContext, + }); + }); + + it('does not mutate the arguments it was given', async () => { + const {tool} = createTool(); + const args = {user_id: 'user123'}; + + await tool.runAsync({args, toolContext: createToolContext()}); + + expect(args).toEqual({user_id: 'user123'}); + }); + + it('overwrites connector arguments supplied by the model', async () => { + const {tool, restApiTool} = createTool(); + + await tool.runAsync({ + args: {connection_name: 'attacker-conn', operation: 'DELETE_ENTITY'}, + toolContext: createToolContext(), + }); + + expect(restApiTool.runAsync).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ + connection_name: 'test-conn', + operation: 'LIST', + }), + }), + ); + }); + + it('forwards the resolved access token as dynamic auth config', async () => { + const {tool, restApiTool} = createTool({ + authScheme: BEARER_SCHEME, + authCredential: bearerCredential('mocked_token'), + credentialKey: 'test-key', + }); + + await tool.runAsync({ + args: {user_id: 'user123', page_size: 10}, + toolContext: createToolContext(), + }); + + expect(restApiTool.runAsync).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ + dynamic_auth_config: { + 'oauth2_auth_code_flow.access_token': 'mocked_token', + }, + }), + }), + ); + }); + + it('falls back to an empty token and passes unknown arguments through', async () => { + const {tool, restApiTool} = createTool({ + authScheme: BEARER_SCHEME, + authCredential: bearerCredential(), + credentialKey: 'test-key', + }); + + await tool.runAsync({ + args: { + user_id: 'user456', + filter: 'some_filter', + sortByColumns: ['a', 'b'], + }, + toolContext: createToolContext(), + }); + + expect(restApiTool.runAsync).toHaveBeenCalledWith( + expect.objectContaining({ + args: { + user_id: 'user456', + filter: 'some_filter', + sortByColumns: ['a', 'b'], + dynamic_auth_config: {'oauth2_auth_code_flow.access_token': {}}, + connection_name: 'test-conn', + service_name: 'test-service', + host: 'test.example.com', + entity: 'TestEntity', + operation: 'LIST', + action: 'TestAction', + }, + }), + ); + }); + + it('passes the configured credential key to the auth handler', async () => { + const authCredential = bearerCredential('mocked_token'); + const fromToolContext = vi.spyOn(ToolAuthHandler, 'fromToolContext'); + const {tool} = createTool({ + authScheme: BEARER_SCHEME, + authCredential, + credentialKey: 'test-key', + }); + const toolContext = createToolContext(); + + await tool.runAsync({args: {}, toolContext}); + + expect(fromToolContext).toHaveBeenCalledWith( + toolContext, + BEARER_SCHEME, + authCredential, + {credentialKey: 'test-key'}, + ); + }); + + it('reports pending authorization without calling the REST tool', async () => { + const {tool, restApiTool} = createTool({authScheme: API_KEY_SCHEME}); + + const result = await tool.runAsync({ + args: {user_id: 'user123'}, + toolContext: createToolContext(), + }); + + expect(result).toEqual({ + pending: true, + message: 'Needs your authorization to access your data.', + }); + expect(restApiTool.runAsync).not.toHaveBeenCalled(); + }); +}); From 4a9afae8f1798b41aa8379f80fa0c1723e32eaac Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 09:19:51 -0700 Subject: [PATCH 2/5] Fix: decide the connector auth override once per connection connectorAuth() only depends on the connection details, so calling it inside the per-operation loop re-evaluated it for every tool and repeated the override warning once per generated operation. --- .../application_integration_toolset.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/tools/application_integration_tool/application_integration_toolset.ts b/core/src/tools/application_integration_tool/application_integration_toolset.ts index 71ab75084..e1f42bd15 100644 --- a/core/src/tools/application_integration_tool/application_integration_toolset.ts +++ b/core/src/tools/application_integration_tool/application_integration_toolset.ts @@ -232,6 +232,8 @@ export class ApplicationIntegrationToolset extends BaseToolset { this.options.toolInstructions ?? '', ); + const connectorAuth = this.connectorAuth(connectionDetails); + for (const parsed of new OpenApiSpecParser().parse(spec)) { const operation: ConnectorOperationObject = parsed.operation; const restApiTool = createRestApiTool(parsed); @@ -249,7 +251,7 @@ export class ApplicationIntegrationToolset extends BaseToolset { action: operation['x-entity'] ? '' : (operation['x-action'] ?? ''), operation: operation['x-operation'] ?? '', restApiTool, - ...this.connectorAuth(connectionDetails), + ...connectorAuth, credentialKey: this.options.credentialKey, }), ); From 371c370a96322aa80c9c9a1f31492e2673c69bcc Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 09:40:23 -0700 Subject: [PATCH 3/5] Test: assert the connector toolFilter keeps a listed tool, not just that it drops others --- .../application_integration_toolset_test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/core/test/tools/application_integration_tool/application_integration_toolset_test.ts b/core/test/tools/application_integration_tool/application_integration_toolset_test.ts index 531f5eed6..2b6b6c7d2 100644 --- a/core/test/tools/application_integration_tool/application_integration_toolset_test.ts +++ b/core/test/tools/application_integration_tool/application_integration_toolset_test.ts @@ -567,16 +567,21 @@ describe('ApplicationIntegrationToolset', () => { }); describe('tool filtering and initialization', () => { - it('selects connector tools by name', async () => { + it.each([ + ['keeps a listed connector tool', ['list_issues'], ['list_issues']], + ['drops an unlisted connector tool', ['other_tool'], []], + ])('%s', async (_description, toolFilter, expected) => { const toolset = new ApplicationIntegrationToolset({ project: PROJECT, location: LOCATION, connection: 'test-connection', entityOperations: {Issues: ['LIST']}, - toolFilter: ['other_tool'], + toolFilter, }); - await expect(toolset.getTools()).resolves.toEqual([]); + const tools = await toolset.getTools(); + + expect(tools.map((tool) => tool.name)).toEqual(expected); }); it('applies a tool predicate when a context is supplied', async () => { From f6f1f44db33934c0dd49dc081dc5aebb211c86b5 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 10:20:10 -0700 Subject: [PATCH 4/5] Fix: retry a failed toolset initialization instead of memoising the rejection A transient failure on the first getTools() left the memoised promise rejected forever, so the toolset could never produce a tool again. The memo is now cleared on failure, and the connector tools are published only once all of them are built so a retry cannot append duplicates. Also corrects the connector chain test, which asserted the corrupted triggerId the fragment bug produced. --- .../application_integration_toolset.ts | 17 ++++-- ...on_integration_toolset_integration_test.ts | 6 +- .../application_integration_toolset_test.ts | 56 +++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/core/src/tools/application_integration_tool/application_integration_toolset.ts b/core/src/tools/application_integration_tool/application_integration_toolset.ts index e1f42bd15..4d933b509 100644 --- a/core/src/tools/application_integration_tool/application_integration_toolset.ts +++ b/core/src/tools/application_integration_tool/application_integration_toolset.ts @@ -130,7 +130,7 @@ export class ApplicationIntegrationToolset extends BaseToolset { private readonly options: ApplicationIntegrationToolsetOptions; private readonly connection: string; - private readonly tools: IntegrationConnectorTool[] = []; + private tools: IntegrationConnectorTool[] = []; private openapiToolset?: OpenAPIToolset; private initPromise?: Promise; @@ -187,10 +187,14 @@ export class ApplicationIntegrationToolset extends BaseToolset { /** * Fetches the spec and builds the tools once, no matter how many callers race - * on the first `getTools()`. + * on the first `getTools()`. A failed attempt is not memoised, so a transient + * network failure does not leave the toolset permanently empty. */ private initialize(): Promise { - this.initPromise ??= this.fetchAndBuildTools(); + this.initPromise ??= this.fetchAndBuildTools().catch((err: unknown) => { + this.initPromise = undefined; + throw err; + }); return this.initPromise; } @@ -233,6 +237,7 @@ export class ApplicationIntegrationToolset extends BaseToolset { ); const connectorAuth = this.connectorAuth(connectionDetails); + const tools: IntegrationConnectorTool[] = []; for (const parsed of new OpenApiSpecParser().parse(spec)) { const operation: ConnectorOperationObject = parsed.operation; @@ -240,7 +245,7 @@ export class ApplicationIntegrationToolset extends BaseToolset { restApiTool.configureAuthScheme(authScheme); restApiTool.configureAuthCredential(authCredential); - this.tools.push( + tools.push( new IntegrationConnectorTool({ name: restApiTool.name, description: restApiTool.description, @@ -256,6 +261,10 @@ export class ApplicationIntegrationToolset extends BaseToolset { }), ); } + + // Published only once every tool is built, so a retry after a partial + // failure cannot leave duplicates behind. + this.tools = tools; } /** diff --git a/core/test/tools/application_integration_tool/application_integration_toolset_integration_test.ts b/core/test/tools/application_integration_tool/application_integration_toolset_integration_test.ts index 7d59856f2..f12609f55 100644 --- a/core/test/tools/application_integration_tool/application_integration_toolset_integration_test.ts +++ b/core/test/tools/application_integration_tool/application_integration_toolset_integration_test.ts @@ -154,9 +154,13 @@ describe('ApplicationIntegrationToolset connector chain', () => { expect(`${requestUrl.origin}${requestUrl.pathname}`).toBe( EXECUTE_URL_PREFIX, ); + // The `#list_Issues` fragment only keeps the generated spec paths distinct; + // it must not reach the service as part of the trigger ID. expect(requestUrl.searchParams.get('triggerId')).toBe( - 'api_trigger/ExecuteConnection#list_Issues', + 'api_trigger/ExecuteConnection', ); + expect(requestUrl.hash).toBe(''); + expect(String(url)).not.toContain('%23'); expect(init?.method).toBe('POST'); expect(JSON.parse(String(init?.body))).toEqual({ connectionName: `projects/${PROJECT}/locations/${LOCATION}/connections/${CONNECTION}`, diff --git a/core/test/tools/application_integration_tool/application_integration_toolset_test.ts b/core/test/tools/application_integration_tool/application_integration_toolset_test.ts index 2b6b6c7d2..0b10eaee6 100644 --- a/core/test/tools/application_integration_tool/application_integration_toolset_test.ts +++ b/core/test/tools/application_integration_tool/application_integration_toolset_test.ts @@ -602,6 +602,62 @@ describe('ApplicationIntegrationToolset', () => { ); }); + it('retries after a failed initialization instead of staying empty', async () => { + integrationClient.getOpenApiSpecForConnection + .mockRejectedValueOnce(new Error('An unexpected error occurred: EAI')) + .mockResolvedValue( + specWithOperations([ + { + operationId: 'list_issues', + 'x-entity': 'Issues', + 'x-operation': 'LIST_ENTITIES', + }, + ]), + ); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + }); + + await expect(toolset.getTools()).rejects.toThrow( + 'An unexpected error occurred: EAI', + ); + + await expect(toolset.getTools()).resolves.toHaveLength(1); + }); + + it('does not duplicate tools across a retry', async () => { + const spec = specWithOperations([ + { + operationId: 'list_issues', + 'x-entity': 'Issues', + 'x-operation': 'LIST_ENTITIES', + }, + ]); + connectionsClient.getConnectionDetails + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValue({ + name: 'test-connection', + serviceName: 'test-service', + host: 'test.host', + authOverrideEnabled: false, + }); + integrationClient.getOpenApiSpecForConnection.mockResolvedValue(spec); + const toolset = new ApplicationIntegrationToolset({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + entityOperations: {Issues: ['LIST']}, + }); + + await expect(toolset.getTools()).rejects.toThrow('transient'); + const tools = await toolset.getTools(); + + expect(tools.map((tool) => tool.name)).toEqual(['list_issues']); + }); + it('fetches the spec once for concurrent callers', async () => { const toolset = new ApplicationIntegrationToolset({ project: PROJECT, From b8430c102849b2c8505ed0aacefdcbcf3e47933a Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 10:41:57 -0700 Subject: [PATCH 5/5] Refactor: drop the toolset auth-config field that nothing reads ApplicationIntegrationToolset exposed a public authConfig whose documented workflow - set exchangedAuthCredential on it before getTools() - had no caller anywhere: in adk-python the flow reaches the config through BaseToolset.get_auth_config(), and adk-js has no such hook. At runtime the exchanged credential is already resolved per call by ToolAuthHandler.prepareAuthCredentials, which reads it from tool-context state. Removing the field also removes the clone-on-read branch in getTools and cloneWithAuthCredential, and lets the DEFAULT_CREDENTIAL_KEY export in tool_auth_handler.ts - added solely to feed it - revert entirely, so this stack no longer touches that file. The three tests that drove the field by hand go with it; the remaining auth tests still pin that caller-supplied auth reaches the connector tools only when the connection enables overrides. The hook and this path will land together in the queued follow-up. Also folds the toolset's one-use connection copy into a local and imports the x-* extension type from the module that produces it. --- .../application_integration_toolset.ts | 73 +++---------------- .../openapi_spec_parser/tool_auth_handler.ts | 5 +- .../application_integration_toolset_test.ts | 63 ---------------- 3 files changed, 12 insertions(+), 129 deletions(-) diff --git a/core/src/tools/application_integration_tool/application_integration_toolset.ts b/core/src/tools/application_integration_tool/application_integration_toolset.ts index 4d933b509..e297c4041 100644 --- a/core/src/tools/application_integration_tool/application_integration_toolset.ts +++ b/core/src/tools/application_integration_tool/application_integration_toolset.ts @@ -11,20 +11,19 @@ import { AuthCredentialTypes, } from '../../auth/auth_credential.js'; import {AuthScheme} from '../../auth/auth_schemes.js'; -import {AuthConfig} from '../../auth/auth_tool.js'; import {experimental} from '../../utils/experimental.js'; import {logger} from '../../utils/logger.js'; import {parseServiceAccountCredential} from '../../utils/service_account_utils.js'; import {BaseTool} from '../base_tool.js'; import {BaseToolset, ToolPredicate} from '../base_toolset.js'; import {OpenApiSpecParser} from '../openapi_tool/openapi_spec_parser/openapi_spec_parser.js'; -import {DEFAULT_CREDENTIAL_KEY} from '../openapi_tool/openapi_spec_parser/tool_auth_handler.js'; import {OpenAPIToolset} from '../openapi_tool/openapi_toolset.js'; import {createRestApiTool} from '../openapi_tool/rest_api_tool.js'; import { ConnectionDetails, ConnectionsClient, } from './clients/connections_client.js'; +import {ConnectorOperationExtensions} from './clients/connector_spec_builders.js'; import { EntityOperations, IntegrationClient, @@ -39,16 +38,6 @@ const INVALID_REQUEST_MESSAGE = 'Invalid request, Either integration or (connection and (entity_operations' + ' or actions)) should be provided.'; -/** Extension keys the connector spec adds to each generated operation. */ -interface ConnectorOperationExtensions { - 'x-operation'?: string; - 'x-entity'?: string; - 'x-action'?: string; -} - -type ConnectorOperationObject = OpenAPIV3.OperationObject & - ConnectorOperationExtensions; - /** Constructor options for {@link ApplicationIntegrationToolset}. */ export interface ApplicationIntegrationToolsetOptions { /** The Google Cloud project ID. */ @@ -119,17 +108,7 @@ export class ApplicationIntegrationToolset extends BaseToolset { readonly project: string; readonly location: string; - /** - * The auth configuration the connector tools were built with, if any. - * - * Set `exchangedAuthCredential` on it before calling `getTools()` to hand the - * connector tools an exchanged credential; the cached tools are left - * untouched and clones carrying the credential are returned instead. - */ - readonly authConfig?: AuthConfig; - private readonly options: ApplicationIntegrationToolsetOptions; - private readonly connection: string; private tools: IntegrationConnectorTool[] = []; private openapiToolset?: OpenAPIToolset; private initPromise?: Promise; @@ -141,16 +120,8 @@ export class ApplicationIntegrationToolset extends BaseToolset { } this.options = options; - this.connection = options.connection ?? ''; this.project = options.project; this.location = options.location; - this.authConfig = options.authScheme - ? { - authScheme: options.authScheme, - rawAuthCredential: options.authCredential, - credentialKey: options.credentialKey ?? DEFAULT_CREDENTIAL_KEY, - } - : undefined; } @experimental @@ -161,7 +132,7 @@ export class ApplicationIntegrationToolset extends BaseToolset { return this.openapiToolset.getTools(context); } - const selected = this.tools.filter((tool) => { + return this.tools.filter((tool) => { if (Array.isArray(this.toolFilter) && this.toolFilter.length > 0) { return this.toolFilter.includes(tool.name); } @@ -170,14 +141,6 @@ export class ApplicationIntegrationToolset extends BaseToolset { } return true; }); - - const exchanged = this.authConfig?.exchangedAuthCredential; - if (!exchanged) { - return selected; - } - return selected.map((tool) => - tool.authScheme ? cloneWithAuthCredential(tool, exchanged) : tool, - ); } @experimental @@ -199,7 +162,12 @@ export class ApplicationIntegrationToolset extends BaseToolset { } private async fetchAndBuildTools(): Promise { - const {project, location, serviceAccountJson} = this.options; + const { + project, + location, + connection = '', + serviceAccountJson, + } = this.options; const integrationClient = new IntegrationClient({ project, location, @@ -228,7 +196,7 @@ export class ApplicationIntegrationToolset extends BaseToolset { const connectionDetails = await new ConnectionsClient({ project, location, - connection: this.connection, + connection, serviceAccountJson, }).getConnectionDetails(); const spec = await integrationClient.getOpenApiSpecForConnection( @@ -240,7 +208,8 @@ export class ApplicationIntegrationToolset extends BaseToolset { const tools: IntegrationConnectorTool[] = []; for (const parsed of new OpenApiSpecParser().parse(spec)) { - const operation: ConnectorOperationObject = parsed.operation; + const operation: OpenAPIV3.OperationObject = + parsed.operation; const restApiTool = createRestApiTool(parsed); restApiTool.configureAuthScheme(authScheme); restApiTool.configureAuthCredential(authCredential); @@ -331,23 +300,3 @@ function buildSpecCredentials(serviceAccountJson?: string): { }, }; } - -function cloneWithAuthCredential( - tool: IntegrationConnectorTool, - authCredential: AuthCredential, -): IntegrationConnectorTool { - return new IntegrationConnectorTool({ - name: tool.name, - description: tool.description, - connectionName: tool.connectionName, - connectionHost: tool.connectionHost, - connectionServiceName: tool.connectionServiceName, - entity: tool.entity, - operation: tool.operation, - action: tool.action, - restApiTool: tool.restApiTool, - authScheme: tool.authScheme, - authCredential, - credentialKey: tool.credentialKey, - }); -} diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts index 2f6f7060e..dc2650a75 100644 --- a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts +++ b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts @@ -11,9 +11,6 @@ import {AuthConfig} from '../../../auth/auth_tool.js'; import {experimental} from '../../../utils/experimental.js'; import {AutoAuthCredentialExchanger} from '../auth/credential_exchangers/auto_auth_credential_exchanger.js'; -/** Credential key used when a tool does not configure one. */ -export const DEFAULT_CREDENTIAL_KEY = 'default_openapi_key'; - export interface AuthPreparationResult { state: 'pending' | 'done'; authCredential?: AuthCredential; @@ -86,7 +83,7 @@ export class ToolAuthHandler { const authConfig: AuthConfig = { authScheme: this.authScheme, rawAuthCredential: this.authCredential, - credentialKey: this.credentialKey || DEFAULT_CREDENTIAL_KEY, + credentialKey: this.credentialKey || 'default_openapi_key', }; // A credential returned by an auth response was supplied interactively by diff --git a/core/test/tools/application_integration_tool/application_integration_toolset_test.ts b/core/test/tools/application_integration_tool/application_integration_toolset_test.ts index 0b10eaee6..5cc8b4bf2 100644 --- a/core/test/tools/application_integration_tool/application_integration_toolset_test.ts +++ b/core/test/tools/application_integration_tool/application_integration_toolset_test.ts @@ -501,69 +501,6 @@ describe('ApplicationIntegrationToolset', () => { ' authOverrideEnabled is not enabled in the connection.', ); }); - - it('returns clones carrying the exchanged credential', async () => { - connectionsClient.getConnectionDetails.mockResolvedValue({ - name: 'test-connection', - serviceName: 'test-service', - host: 'test.host', - authOverrideEnabled: true, - }); - const toolset = new ApplicationIntegrationToolset({ - project: PROJECT, - location: LOCATION, - connection: 'test-connection', - entityOperations: {Issues: ['LIST']}, - authScheme: OAUTH2_SCHEME, - authCredential: RAW_CREDENTIAL, - }); - const [original] = await toolset.getTools(); - - const exchanged: AuthCredential = { - authType: AuthCredentialTypes.OAUTH2, - oauth2: {...RAW_CREDENTIAL.oauth2, accessToken: 'exchanged-token'}, - }; - if (!toolset.authConfig) { - expect.fail('the toolset should expose an auth config'); - } - toolset.authConfig.exchangedAuthCredential = exchanged; - const [resolved] = await toolset.getTools(); - - expect(resolved).not.toBe(original); - expect(resolved).toMatchObject({authCredential: exchanged}); - expect(original).toMatchObject({authCredential: RAW_CREDENTIAL}); - }); - - it('leaves tools without an auth scheme unexchanged', async () => { - const toolset = new ApplicationIntegrationToolset({ - project: PROJECT, - location: LOCATION, - connection: 'test-connection', - entityOperations: {Issues: ['LIST']}, - authScheme: OAUTH2_SCHEME, - authCredential: RAW_CREDENTIAL, - }); - const [original] = await toolset.getTools(); - if (!toolset.authConfig) { - expect.fail('the toolset should expose an auth config'); - } - toolset.authConfig.exchangedAuthCredential = RAW_CREDENTIAL; - - const [resolved] = await toolset.getTools(); - - expect(resolved).toBe(original); - }); - - it('exposes no auth config when no scheme was supplied', () => { - const toolset = new ApplicationIntegrationToolset({ - project: PROJECT, - location: LOCATION, - connection: 'test-connection', - entityOperations: {Issues: ['LIST']}, - }); - - expect(toolset.authConfig).toBeUndefined(); - }); }); describe('tool filtering and initialization', () => {