Skip to content

Migrate a2a/python-agent to a2a-sdk 1.x - #47

Merged
jplock merged 13 commits into
mainfrom
feat/a2a-sdk-1x
Jul 28, 2026
Merged

jplock merged 13 commits into
mainfrom
feat/a2a-sdk-1x

Conversation

@jplock

@jplock jplock commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Stacked on #46 — the last of the pinned majors. Merge #46 first; this retargets to main automatically.

The migration

a2a-sdk 1.0 removed a2a.server.apps, which is what was breaking this example before #46 pinned it below the major.

0.2 1.x
A2AStarletteApplication removed — compose create_agent_card_routes + create_jsonrpc_routes into a Starlette app
AgentCard.url supported_interfaces=[AgentInterface(...)]
camelCase fields snake_case (default_input_modes, security_schemes)
securitySchemes dict typed SecurityScheme(open_id_connect_security_scheme=...)
security=[...] security_requirements=[SecurityRequirement(...)]
app.add_middleware(...) after build() middleware in the Starlette constructor

The executor now emits a message rather than an artifact — v1.0 enforces the streaming rules, and an artifact with no preceding Task event is an error.

The well-known path moved to /.well-known/agent-card.json back in 0.3.0. Fixed in the auth-middleware allowlist (leaving it stale would have made card discovery 401), the startup banner, the README, and the test.

The test wasn't testing anything

accepts valid bearer token asserted only res.status === 200 and jsonrpc: "2.0". A JSON-RPC error response has both — so it passed no matter what the agent did.

Tightening it to assert the executor's actual output immediately exposed that the request had been failing:

{"code":-32601,"message":"Method not found"}

tasks/send is v0.2-era and isn't served by 1.x. The v0.3 compat adapter this agent enables maps message/send; the native 1.0 name is SendMessage. The test now uses message/send with the v0.3 params shape and asserts the identity message actually comes back.

Agent card output

The card serializes in ProtoJSON, and the SDK also flattens type/openIdConnectUrl alongside for clients written against the older schema:

"securitySchemes": {
  "vouch_oidc": {
    "openIdConnectSecurityScheme": { "openIdConnectUrl": "https://us.vouch.sh/.well-known/openid-configuration" },
    "type": "openIdConnect",
    "openIdConnectUrl": "https://us.vouch.sh/.well-known/openid-configuration"
  }
}

Also

Verification

27/27 build and smoke · web, mcp, a2a, native, claims suites = 69 passed, 0 failures · ruff clean

🤖 Generated with Claude Code

jplock and others added 11 commits July 28, 2026 14:14
CI builds images but never runs them, and every Python example used
floor-only `>=` pins with no lockfile. Three examples were resolving to
breaking majors and failing at import with nothing to catch it:

  mcp/remote-server-py   mcp 2.0.0 removed mcp.server.fastmcp
  mcp/credential-broker  same
  a2a/python-agent       a2a-sdk 1.1.2 removed a2a.server.apps

Pin those two SDKs below the breaking major (mcp 1.29.0, a2a-sdk 0.2.16)
and lock every ecosystem at what it already resolves to today. No
intentional version increases; the migrations to mcp 2.x and a2a-sdk 1.x
land separately.

Python moves to requirements.in (floors) compiled to a fully pinned
requirements.txt via `uv pip compile --universal`. Universal resolution is
required because development is arm64 macOS and CI builds linux/amd64.

npm switches to `npm ci` and drops the `package-lock.json*` glob, whose
trailing `*` silently tolerated the 8 missing lockfiles. Also adds
--locked for cargo, frozen for bundler, --locked-mode for NuGet,
composer.lock for PHP, and a real Gemfile.lock (the committed one was 0
bytes). Removes `go mod tidy` from the go-oidc build, which mutated
go.mod/go.sum at image build time.

Adds .dockerignore to all 27 build contexts. There were none, so `COPY . .`
shipped the host's node_modules and target/ into images and overwrote what
the Dockerfile had just installed -- which is why the missing lockfiles
went unnoticed.

Verified: all 27 images build, all 16 servers boot and serve, all 5 static
SPAs still substitute their config placeholders at container start.

Co-Authored-By: Claude <[email protected]>
CI only built images, so anything that failed at import or boot stayed
invisible -- which is how three examples shipped broken SDK majors. Add
scripts/smoke.sh, which starts an image and probes it with throwaway
credentials, and wire it into the matrix after the build.

Probes are per category: web apps must render /; static SPAs must render /
and have their __VOUCH_* placeholders substituted into the built bundle;
MCP and A2A servers must serve well-known metadata and 401 unauthenticated
calls; native CLIs must get past module loading.

The SPA placeholder assertion is the load-bearing one. entrypoint.sh
substitutes config with `sed -i ... assets/*.js` and exits 0 whether or not
the glob matched, so a bundler that renames its output directory would ship
an image serving a literal __VOUCH_CLIENT_ID__ with every other check
green. Verified against deliberately broken images: the SPA check catches a
stale sed glob and the native check catches a missing module, both exit 1.

Base images: Node 25 reached EOL on 2026-06-01, so all 10 Node examples
move to Node 26 (current stable, Active LTS from 2026-10-28; Angular 21's
CLI already allows >=24). Rust 1.94 -> 1.97, alpine 3.23 -> 3.24, and pin
the two floating tags -- nginx:alpine -> nginx:1.31-alpine (same digest
today) and composer:latest -> composer:2.9. Spring Boot's JDK images are
left alone; they move with the Boot upgrade.

Also adds the github-actions Dependabot ecosystem, without which the
SHA-pinned actions that secure_workflows.yml enforces were never updated,
and sets contents:read plus persist-credentials:false to clear zizmor.

Corrects CLAUDE.md, which undercounted spa (5 vs 6), native (3 vs 6) and
mcp (2 vs 3) examples, said 22 Dockerfiles instead of 27, and claimed no
test suite exists. README no longer says SPA examples need no client
secret, which was never true of spa/bff-express.

Verified: all 27 build and pass smoke on Node 26 / Rust 1.97.

Co-Authored-By: Claude <[email protected]>
The test harness looked for ~/.vouch/{cookie.txt,config.json}, which the
Vouch CLI no longer writes. Point it at $XDG_STATE_HOME/vouch/cookie.txt
and $XDG_CONFIG_HOME/vouch/config.json, honouring both env vars and
falling back to the spec defaults. The whole end-to-end suite was
unrunnable before this.

Adds tests/tests/claims.spec.js, which establishes what a Vouch access
token actually is. This blocks the JWKS refactor, because the repo
contradicted itself: oidc-flow.js said access tokens were "opaque" and
"HS256-signed and not verifiable via JWKS", while README.md said they were
RFC 9068 JWTs.

The README was right:

  header  {"alg":"ES256","typ":"at+jwt","kid":"vouch-oidc-kms-04a0ccc2e45995c1"}
  claims  acr amr aud auth_time client_id email email_verified exp
          hardware_verified iat iss jti nbf scope sub

The signature verifies against /oauth/jwks with stdlib crypto, so every
example can verify instead of blind-decoding.

Three findings beyond that:

- acr and amr arrive on both tokens without being requested, carrying
  acr=...aal3 and amr=["hwk","pin","user"]. These are the claims Vouch
  actually advertises in claims_supported, and amr:hwk is RFC 8176 for a
  hardware-secured key -- the standards-defined form of hardware_verified.
- hardware_aaguid is never issued, despite being documented in the README
  and rendered by several examples.
- Introspection does not return hardware_verified, so it is not a fallback
  route to that claim.
- An RFC 8707 resource parameter does narrow aud to the requested value,
  which is what makes resource-server audience validation possible at all;
  the default aud is the calling client's own client_id.

Committed as a spec rather than run as a throwaway probe so a future change
to the token format fails one test instead of silently degrading 19
examples.

Co-Authored-By: Claude <[email protected]>
The MCP and A2A servers accepted any Vouch-issued JWT. remote-server-ts
checked only the issuer while publishing a `resource` identifier it never
enforced; the three Python servers passed verify_aud=False. A token minted
for an unrelated client was accepted everywhere.

Each server now derives one RESOURCE constant, publishes it in its RFC 9728
metadata, and requires it as `aud`. Clients opt in by sending the RFC 8707
`resource` parameter, which makes Vouch narrow the audience -- without that
the audience is the calling client's own client_id and there is nothing a
resource server can check.

They also require `typ: at+jwt` (RFC 9068), which structurally rejects ID
tokens. That matters because the test harness was handing resource servers
`tokens.id_token || tokens.access_token`, so every one of these servers was
being exercised with an ID token. obtainAccessToken now returns the access
token and fails loudly if there isn't one.

Added a negative test: a valid, correctly-signed, unexpired Vouch token
minted for a different client is now rejected with 401. It passes for both
remote servers and is skipped for the credential broker.

The broker keeps verify_aud=False deliberately, with a comment explaining
why: it forwards the caller's token to Vouch's /v1/credentials/* endpoints,
which reject audience-narrowed tokens, so it cannot both require a token
minted for itself and spend that token at Vouch. Fixing it properly needs
RFC 8693 token exchange.

Drops hardware_aaguid from these servers -- claims.spec.js shows Vouch never
issues it -- and surfaces acr/amr instead, which are standard, advertised in
claims_supported, and present on both tokens. Corrects the README, which
documented hardware_aaguid as a real claim, and the introspect-token tool,
which told users Vouch access tokens were opaque.

Verified: mcp 15 passed, a2a 3 passed, tsc clean.

Co-Authored-By: Claude <[email protected]>
Every example read hardware_verified by splitting the access token on "."
and base64-decoding the payload, with no signature check -- trusting
whatever bytes it was handed. claims.spec.js established that Vouch access
tokens are ES256-signed RFC 9068 JWTs, so they can simply be verified.

Server-side confidential clients now verify against JWKS with issuer,
audience and typ checks. The audience is the client's own client_id, which
is what Vouch issues when the authorization request carries no RFC 8707
resource parameter. Done here for express-openid, flask-authlib,
nextjs-nextauth and bff-express; the other seven languages follow.

The five browser SPAs deliberately do NOT verify. decodeAccessToken is
renamed decodeUnverifiedForDisplay and carries a comment saying so: a
public client gains nothing by verifying a token it just received over TLS
from the token endpoint, and shipping a JOSE library to the browser to do
it would teach the wrong lesson. The security decision belongs to the
resource server.

Drops hardware_aaguid, which Vouch never issues, and shows acr/amr
instead.

Also adds `import json` back to flask-authlib, which I had removed while
its /userinfo route still used it -- caught by ruff, which is now clean
across all Python examples, and by the Playwright suite.

Verified: web.spec.js 37 passed (1 infra flake), all changed examples build
and pass smoke.

Known issue, unrelated and pre-existing: all five spa.spec.js login tests
fail. Vouch issues no client_secret for application_type spa/native but
still sets token_endpoint_auth_method to client_secret_basic, so the token
endpoint answers "invalid_client: client authentication required" and no
public-client code flow can complete. Passing token_endpoint_auth_method
"none" at creation is silently ignored. Documented in createApp; it needs a
server-side fix.

Co-Authored-By: Claude <[email protected]>
Completes the JWKS refactor across every server-side client and the three
native CLIs. Each verifies issuer, audience and typ=at+jwt using its
ecosystem's own library: PyJWT for fastapi, go-oidc's existing verifier for
Go, NimbusJwtDecoder for Spring, json-jwt for Rails, firebase/php-jwt for
Laravel, JsonWebTokenHandler for ASP.NET, and jsonwebtoken for both Rust
examples. native/node verifies with node:crypto alone so it keeps its
zero-dependency footprint.

Three bugs the end-to-end suite caught that the smoke tests could not,
because all three only fire after a real login:

- Spring: NimbusJwtDecoder.withJwkSetUri defaults to RS256 only, so ES256
  tokens failed as "no matching key(s) found". Separately, Spring Security
  7's JwtValidators.createDefaultWithIssuer chain includes a JwtTypeValidator
  requiring typ=JWT, which rejects every RFC 9068 token; the validator chain
  is now built explicitly.
- Rust: jsonwebtoken 11 panics on first use unless exactly one crypto
  provider feature is enabled. Now pins rust_crypto, matching rustls.
- Flask/Go: removing the old decoder left unused imports and one genuinely
  undefined name. ruff and the Go compiler caught these.

cargo --locked also did its job here: changing a feature flag invalidated
both Cargo.lock files and the Docker build refused to proceed until they
were regenerated.

Also drops hardware_aaguid from the last four READMEs. It is now referenced
only in claims.spec.js, which asserts Vouch does not issue it.

Verified: 27/27 build and smoke; web, mcp, a2a, native and claims suites =
68 passed, 1 infra flake (port collision). ruff, gofmt, go vet, cargo fmt,
clippy and tsc all clean.

Co-Authored-By: Claude <[email protected]>
Routine updates, no source changes beyond what the bumps required.

  go-oidc            v3.17.0 -> v3.20.0 (go-jose v4.1.3 -> v4.1.4)
  go directive       1.25.0 -> 1.26.0, matching the golang:1.26 builder
  Spring Boot        4.0.4 -> 4.1.0
  ASP.NET OIDC       10.0.5 -> 10.0.10
  @types/node        ^25 -> ^26, matching the Node 26 runtime
  Rust crates        cargo update on both examples
  Python floors      raised to what actually resolves today
  npm lockfiles      refreshed across all 11 directories

Spring Boot's pom said java.version 21 while both images were Temurin 25;
now 25 in all three places. Its Dockerfile pins maven:3 to maven:3.9 and
uses -Dmaven.test.skip=true, which is what Boot 4.1's plugin honours when
suppressing test AOT.

Rails had no version constraint at all on puma or sqlite3, so a fresh
resolve could pick anything; both are now pinned, and rails moves to ~> 8.1
to match what the lock already resolved.

Deliberately held back:

  mcp                 <2  and a2a-sdk <0.3, migrated separately
  reqwest             0.12 in axum, pinned by openidconnect 4 -> oauth2 5
  typescript          ~5.9; TS 7 is the native port, a real migration
  next-auth           4.x is genuinely latest-stable, v5 is beta-tagged
  Angular             21; 22 needs TypeScript 6 and lands on its own

Verified: 27/27 build and smoke; web, mcp, a2a, native and claims suites =
69 passed, 0 failures.

Co-Authored-By: Claude <[email protected]>
Angular 22 peers on TypeScript >=6.0 <6.1, so this cannot ride along with
the routine bumps: ~5.9 is too old and 7.0 (the native port) is out of
range. Pins typescript ~6.0.3.

Switches the builder from @angular-devkit/build-angular:application to
@angular/build:application, the supported path since v20, and drops the
now-unused devkit package.

Removes downlevelIteration from tsconfig. TypeScript 6 errors on it as
deprecated, and it was never needed with an ES2022 target.

Note npm audit reports a moderate advisory reachable from @angular/cli via
@modelcontextprotocol/sdk -> @hono/node-server. It is a Windows path
traversal in a build-time dependency, the built bundle is served by nginx
with no node_modules in the runtime image, and `npm audit fix` resolves it
by downgrading the CLI to 21.0.4. Left alone deliberately.

Verified: builds locally and in Docker, smoke passes, config placeholders
still substituted in the emitted bundle.

Co-Authored-By: Claude <[email protected]>
Laravel 13 requires PHP ^8.3, and kovah/laravel-socialite-oidc ^0.7 caps
illuminate/* at ^12 -- so the pin had to move to ^0.8, which added ^13
support. That coupling is why this could not ride along with the routine
bumps. Pulls Symfony 8 components in transitively.

The image was already php:8.5-cli, so the declared ^8.2 floor was
understating what this example actually runs on.

Verified: builds, smoke passes, and the full login and logout flows pass
end-to-end against Vouch.

Co-Authored-By: Claude <[email protected]>
mcp 2.0 removed mcp.server.fastmcp, which is what was breaking these two
examples before the pins went in.

  FastMCP                  -> MCPServer
  mcp.server.fastmcp       -> mcp.server.mcpserver
  host/port/json_response   moved from the constructor to run()

Both servers also drop their contextvars side channel. 2.0's AccessToken
carries a `claims` field, so the verified payload rides on the SDK's own
per-request auth context and tools read it via get_access_token(). That
removes the assumption the old code depended on -- that verify_token and
the tool body share an asyncio task context -- which 2.0's dispatcher no
longer guarantees.

Verified: both build, smoke passes, and the full MCP suite passes 15/15,
including whoami and sensitive-action, which are what actually exercise the
new auth context, plus the wrong-audience rejection test.

Co-Authored-By: Claude <[email protected]>
The last of the pinned majors. a2a-sdk 1.0 removed a2a.server.apps, which is
what was breaking this example before the pin went in.

  A2AStarletteApplication  removed -- compose create_agent_card_routes and
                           create_jsonrpc_routes into a Starlette app directly
  AgentCard.url            -> supported_interfaces[AgentInterface]
  camelCase fields         -> snake_case (default_input_modes, security_schemes)
  security dict            -> typed SecurityScheme/OpenIdConnectSecurityScheme
  security                 -> security_requirements[SecurityRequirement]
  middleware               moved into the Starlette constructor

The executor now emits a message rather than an artifact. v1.0 enforces the
streaming rules, and an artifact with no preceding Task event is an error.

The well-known path moved to /.well-known/agent-card.json back in 0.3.0.
Fixed in the agent's auth allowlist -- where leaving it stale would have made
card discovery 401 -- plus the startup banner, the README and the test.

## The test was not testing anything

`accepts valid bearer token` asserted only HTTP 200 and jsonrpc:"2.0". A
JSON-RPC error response has both, so it passed regardless of what the agent
did. Tightening it to assert the executor's actual output revealed the
request had been failing all along:

    {"code":-32601,"message":"Method not found"}

`tasks/send` is v0.2-era and is not served by 1.x. The v0.3 compat adapter,
which this agent enables, maps `message/send`; the native 1.0 name is
`SendMessage`. Switched the test to `message/send` with the v0.3 params
shape, and it now asserts the identity message comes back.

Also simplifies scripts/smoke.sh, which no longer needs to accept either
well-known path, and corrects CLAUDE.md, which still documented the old
~/.vouch/ credential location.

Verified: 27/27 build and smoke; web, mcp, a2a, native and claims suites =
69 passed, 0 failures.

Co-Authored-By: Claude <[email protected]>
Base automatically changed from chore/reproducibility-and-token-verification to main July 28, 2026 23:38
jplock and others added 2 commits July 28, 2026 19:39
The last of the pinned majors. a2a-sdk 1.0 removed a2a.server.apps, which is
what was breaking this example before the pin went in.

  A2AStarletteApplication  removed -- compose create_agent_card_routes and
                           create_jsonrpc_routes into a Starlette app directly
  AgentCard.url            -> supported_interfaces[AgentInterface]
  camelCase fields         -> snake_case (default_input_modes, security_schemes)
  security dict            -> typed SecurityScheme/OpenIdConnectSecurityScheme
  security                 -> security_requirements[SecurityRequirement]
  middleware               moved into the Starlette constructor

The executor now emits a message rather than an artifact. v1.0 enforces the
streaming rules, and an artifact with no preceding Task event is an error.

The well-known path moved to /.well-known/agent-card.json back in 0.3.0.
Fixed in the agent's auth allowlist -- where leaving it stale would have made
card discovery 401 -- plus the startup banner, the README and the test.

## The test was not testing anything

`accepts valid bearer token` asserted only HTTP 200 and jsonrpc:"2.0". A
JSON-RPC error response has both, so it passed regardless of what the agent
did. Tightening it to assert the executor's actual output revealed the
request had been failing all along:

    {"code":-32601,"message":"Method not found"}

`tasks/send` is v0.2-era and is not served by 1.x. The v0.3 compat adapter,
which this agent enables, maps `message/send`; the native 1.0 name is
`SendMessage`. Switched the test to `message/send` with the v0.3 params
shape, and it now asserts the identity message comes back.

Also simplifies scripts/smoke.sh, which no longer needs to accept either
well-known path, and corrects CLAUDE.md, which still documented the old
~/.vouch/ credential location.

Verified: 27/27 build and smoke; web, mcp, a2a, native and claims suites =
69 passed, 0 failures.

Co-Authored-By: Claude <[email protected]>
@jplock
jplock merged commit 104ba13 into main Jul 28, 2026
28 checks passed
@jplock
jplock deleted the feat/a2a-sdk-1x branch July 28, 2026 23:46
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