Skip to content

Feat: port the Application Integration / Integration Connectors clients from adk-python (Part 1/2) - #578

Open
AmaadMartin wants to merge 5 commits into
mainfrom
feat/application-integration-toolset-part1
Open

Feat: port the Application Integration / Integration Connectors clients from adk-python (Part 1/2)#578
AmaadMartin wants to merge 5 commits into
mainfrom
feat/application-integration-toolset-part1

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    Closes: #issue_number
    Related: #issue_number
  2. Or, if no issue exists, describe the change:
    Problem: adk-python ships a first-class Google Cloud Application Integration / Integration Connectors toolset (src/google/adk/tools/application_integration_tool/). adk-js has nothing equivalent, so a TypeScript user who wants an agent to LIST/GET/CREATE/UPDATE/DELETE entities on a connector, or invoke a connector action, has to hand-write the OpenAPI spec and the Application Integration :execute plumbing themselves. A case-insensitive search for ApplicationIntegration / IntegrationConnector across core/src, dev/src, integrations/src, tests/ and docs/ returns zero hits today.

Solution: Part 1 of 2 — port the two API clients that generate the OpenAPI spec. Part 2 (stacked on this branch) adds the toolset and the tool that consume them.

This part adds:

  • core/src/tools/application_integration_tool/clients/integration_client.tsIntegrationClient. Calls :generateOpenApiSpec for an integration's API triggers, and assembles the connector spec for a connection's entity operations and actions.
  • core/src/tools/application_integration_tool/clients/connections_client.tsConnectionsClient. Reads connection details, entity schemas and action schemas from the Integration Connectors API, and converts a connector JSON schema into an OpenAPI schema.
  • core/src/tools/application_integration_tool/clients/connector_spec_builders.ts — the spec fragments Python keeps as @staticmethods on ConnectionsClient. They never touch this, so per the JS guidelines they are module-level functions. Python's five near-identical entity path-item builders and five request-schema builders collapse into one ENTITY_OPERATIONS table plus a single buildEntityOperation(), and a refs() helper replaces the {$ref: '#/components/schemas/<name>'} literal that was otherwise spelled out once per field. The fragments are typed as OpenAPIV3 path items and schemas rather than Record<string, unknown>, so no cast is needed to hand the finished document to the parser.
  • core/src/tools/application_integration_tool/clients/api_request.tsAccessTokenProvider + executeApiCall, the token/HTTP/error-mapping layer both clients share.
  • core/src/utils/service_account_utils.tsparseServiceAccountCredential, shared by the token provider and (in part 2) the toolset.
  • core/src/tools/openapi_tool/rest_api_tool.ts — a two-line fix to prepareRequestParams (see below). This is the first change in the repo to emit spec paths carrying a URL fragment, and without it every generated connector path would go out with a corrupted query string.

Only EntityOperations is added to core/src/common.ts — it appears in the toolset's public options type in part 2. The clients themselves stay internal: nothing outside this module constructs them, and adk-python keeps them out of its package __init__ too. They can be published later if a caller needs them.

No new dependency. google-auth-library is already a core dependency and openapi-types is already a devDependency (types only); globalThis.fetch is already the HTTP client used by rest_api_tool.ts and integrations/agent_registry.

Collision check. gh pr list --repo AmaadMartin/adk-js --state open --limit 1000 (477 open PRs) plus gh pr diff --name-only on every plausibly adjacent PR (#462, #463, #464, #465, #528, #529) found no open PR touching core/src/tools/application_integration_tool/. The only textual overlap is the append-only export block in core/src/common.ts, which several unrelated PRs also append to.

Why this PR touches rest_api_tool.ts

The connector spec puts several operations on one endpoint and keeps their spec paths distinct with a fragment:

/v2/.../integrations/ExecuteConnection:execute?triggerId=api_trigger/ExecuteConnection#list_Issues

adk-python drops that fragment before issuing the request — rest_api_tool.py does urlunparse(parsed_url._replace(query="", fragment="")) and re-adds only parse_qs(parsed_url.query) — so the wire value is triggerId=api_trigger/ExecuteConnection.

adk-js did not. prepareRequestParams split the path on ? and handed the whole remainder, fragment included, to URLSearchParams, which percent-encoded it:

?triggerId=api_trigger%2FExecuteConnection%23list_Issues

so the service would receive triggerId = "api_trigger/ExecuteConnection#list_Issues" — a trigger that does not exist — and RestApiTool.runAsync would swallow the failure into {error: 'Failed to execute API call: ...'}. That would have broken every entity operation and every action in connection mode.

The fix mirrors Python: drop the fragment before the query string is read. Integration mode is unaffected (that spec comes from the API and carries no fragments), and no other spec in the repo produces a fragment, so nothing else changes behaviour. Two new cases in rest_api_tool_test.ts pin it, and both fail if the fix is reverted (see the table below).

Parity notes (adk-python is the reference)

The wire format is byte-identical, and was re-verified after the table refactor. Every builder's output was diffed against the Python literals by exec'ing connections_client.py and comparing json.dumps output field by field — including the multi-line list_operation description with its embedded newlines and 16-space indentation. The test expectations in connector_spec_builders_test.ts were generated from that Python output, so drift fails loudly.

Deliberate divergences, all of them local-convention or correctness wins that leave the observable payload unchanged:

  • mTLS endpoints are out of scope. Python routes every endpoint through utils/_mtls_utils.get_api_endpoint(...), which swaps in *.mtls.googleapis.com. adk-js has no mTLS utility (zero hits for mtls across core/src, dev/src, integrations/src) and google-auth-library for Node does not expose the should_use_client_cert() probe the Python helper relies on. This port uses the plain endpoints only; a partial hand-rolled helper would be worse than none. The two Python mTLS tests are correspondingly not ported.
  • Status codes instead of string sniffing. Python decides the "Invalid request…" case with "404" in str(e) or "400" in str(e), which false-positives on any error text containing those digits. This port checks response.status. Same message, no false positives.
  • No hand-rolled token cache. Python caches credentials and checks .expired. google-auth-library's clients already cache and refresh internally, so one client is held per client instance and getAccessToken() is called per request. The two Python cache tests are replaced by a test asserting the auth client is constructed once and consulted per call.
  • Bounded polling. Python's _poll_operation is while not done: ...; time.sleep(1) — unbounded, and it sleeps even after the operation reports done. This port sleeps only between attempts, caps at MAX_POLL_ATTEMPTS, and throws Timed out waiting for operation {id} to complete.
  • A 30s timeout that is actually enforced. fetch has no timeout option, so every request carries AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MS), matching Python's timeout=30.
  • x-goog-user-project. Python prefers the ADC-discovered project over the configured one; this port uses quotaProjectId ?? project, so a caller that names a project gets billed for that project rather than whatever ADC happens to resolve. The header is still omitted entirely when an explicit service account is supplied, as in Python.
  • Options objects instead of positional parameters. Python's ConnectionsClient.connector_payload is a pass-through that never touches this, so it is not reproduced as a method: callers use the module-level convertJsonSchemaToOpenApiSchema directly, which lives beside the other spec-shaping functions.
  • Tool name and instructions are required parameters. Python defaults them to ''; every call site here passes them, so the defaults would be dead.

No any, no @ts-expect-error, no eslint-disable, no coverage suppression was added. The one cast in this part is JSON.parse(...) as OpenAPIV3.Document at the decode boundary, matching the existing openapi_toolset.ts:45 idiom.

Testing Plan

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

npx vitest run --project unit:core core/test/tools/application_integration_tool core/test/utils/service_account_utils_test.ts99 passed, and 100% statements / branches / functions / lines on every file this PR adds (measured with --coverage.include scoped to the new paths).

Also run on the pushed commit: npm run build, npx tsc --noEmit -p core/tsconfig.json, npx eslint "core/**/*.ts", npx prettier --check "core/**/*.ts", bash scripts/check_license.sh, npm run docs:check — all clean.

CI status: green. All five checks pass on the reviewed commit b183bf83 (run 30837733973): run-tests on ubuntu-latest, macos-latest and windows-latest, the cross-language run-tests job, and check-license. Earlier runs on this branch failed on windows-latest, but only ever on pre-existing flaky child-process suites (UnsafeLocalCodeExecutor shell/python 5s timeouts, run_skill_inline_script_tool, run_skill_script_tool, app_loader discovery, build_setup ts_commonjs, load_mcp_resource_e2e) — a different subset each time, none of them touching this diff, and the same job fails on the fork's own main (run 30669370416).

Proof the tests can fail. Each mutation was applied to the source, the targeted test file re-run, then the mutation reverted:

Mutation Test outcome
getConnectorBaseSpec() emits default: 'LIST' instead of 'LIST_ENTITIES' 1 failed — expected { openapi: '3.0.1', …(5) } to deeply equal { openapi: '3.0.1', …(5) }
getConnectionDetails always reads serviceDirectory (drops the host ? tlsServiceDirectory : … branch) 1 failed — expected { name: 'test-connection', …(3) } to deeply equal { … }
executeApiCall treats only 404 (not 400) as an invalid request 1 failed — expected [Function] to throw error including 'Invalid request. Please check the val…' but got 'Request error: 400 Bad Request'
remove url = url.split('#')[0] from prepareRequestParams 2 failed — drops a path fragment instead of folding it into the query, drops a path fragment that carries no query string
drop the list response description from the ENTITY_OPERATIONS table 2 failed — expected { post: { …(7) } } to deeply equal { post: { …(7) } }
point refs() at #/components/schema/ instead of #/components/schemas/ 12 failed — expected { openapi: '3.0.1', …(5) } to deeply equal { openapi: '3.0.1', …(5) }
let buildEntityOperation accept any verb instead of returning undefined 1 failed — promise resolved "{ openapi: '3.0.1', …(5) }" instead of rejecting

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

Not runnable in CI — it needs a real GCP project with Application Integration provisioned and a live connector connection. With ADC pointed at such a project:

  1. Create an integration named ExecuteConnection with trigger api_trigger/ExecuteConnection in the same region as the connection (this is the standard Application Integration setup for connector execution).
  2. const spec = await new IntegrationClient({project, location, connection, entityOperations: {Issues: ['LIST']}}).getOpenApiSpecForConnection('jira', '');
  3. Confirm spec.paths contains /v2/projects/<p>/locations/<l>/integrations/ExecuteConnection:execute?triggerId=api_trigger/ExecuteConnection#list_Issues and spec.components.schemas.connectorInputPayload_Issues reflects the connector's real entity schema.

Checklist

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

Amaad Martin added 3 commits August 3, 2026 08:57
…ents

The Application Integration and Integration Connectors clients both need an
access token (from ADC or an explicit service-account key file) and the same
mapping from transport/status failures onto user-facing messages. Extract that
once so neither client reimplements it.

Token caching is left to google-auth-library rather than hand-rolled, and every
request carries an AbortSignal timeout.
Generates the OpenAPI spec for an Application Integration trigger, or for an
Integration Connector connection's entity operations and actions. Wire strings
(paths, $refs, schema defaults, descriptions) are byte-identical to the Python
reference so the generated spec stays interchangeable.

The long-running-operation poll is bounded instead of looping forever, and it
only sleeps between attempts.
getQuotaProjectId() resolves an auth client of its own, so an unavailable ADC
surfaced the raw google-auth error instead of the message that asks the caller
for a service account.
Amaad Martin added 2 commits August 3, 2026 10:17
…it into the query

The Integration Connector spec appends `#<operation>_<entity>` to the shared
`:execute` endpoint so several operations can occupy distinct spec paths.
prepareRequestParams split the path on '?' and handed the remainder — fragment
included — to URLSearchParams, which percent-encoded it, so the request went out
with triggerId=api_trigger%2FExecuteConnection%23list_Issues and named a trigger
that does not exist.

adk-python drops the fragment before rebuilding the URL
(rest_api_tool.py: urlunparse(parsed._replace(query='', fragment='')));
do the same here.
…them strictly

The five entity path-item builders and the five request-schema builders shared
one body each and differed only in a verb, a summary, a description and one
$ref slug, so they become a single ENTITY_OPERATIONS table plus one
buildEntityOperation(); the five-arm switch in IntegrationClient collapses into
a lookup that keeps the parity error message. A refs() helper replaces the
$ref literal repeated once per field.

The fragments are now typed as OpenAPIV3 path items and schemas, which removes
the 'as OpenAPIV3.Document' cast that laundered a deliberately untyped value
into a strict one. The x-* extension interface moves here, where it is produced,
instead of being redeclared by the consumer.

Also drops ConnectionsClient.connectorPayload, a pass-through to the exported
converter that never touched 'this' (the converter moves next to the other
spec-shaping functions), inlines three one-use indirections, makes the tool
name and instruction parameters required since every caller passes them, and
moves toMessage to core/src/utils/error_utils.ts, where the same expression is
hand-rolled in nine other places.

Byte parity with the Python reference is unchanged: every builder's output was
re-diffed against connections_client.py after the refactor. The test
expectations are the same Python-derived literals, reached through the new
entry point.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant