Migrate a2a/python-agent to a2a-sdk 1.x - #47
Merged
Merged
Conversation
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
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]>
…into feat/a2a-sdk-1x
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #46 — the last of the pinned majors. Merge #46 first; this retargets to
mainautomatically.The migration
a2a-sdk1.0 removeda2a.server.apps, which is what was breaking this example before #46 pinned it below the major.A2AStarletteApplicationcreate_agent_card_routes+create_jsonrpc_routesinto aStarletteappAgentCard.urlsupported_interfaces=[AgentInterface(...)]default_input_modes,security_schemes)securitySchemesdictSecurityScheme(open_id_connect_security_scheme=...)security=[...]security_requirements=[SecurityRequirement(...)]app.add_middleware(...)afterbuild()StarletteconstructorThe executor now emits a message rather than an artifact — v1.0 enforces the streaming rules, and an artifact with no preceding
Taskevent is an error.The well-known path moved to
/.well-known/agent-card.jsonback 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 tokenasserted onlyres.status === 200andjsonrpc: "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:
tasks/sendis v0.2-era and isn't served by 1.x. The v0.3 compat adapter this agent enables mapsmessage/send; the native 1.0 name isSendMessage. The test now usesmessage/sendwith 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/openIdConnectUrlalongside for clients written against the older schema:Also
scripts/smoke.shno longer needs to accept either well-known pathCLAUDE.mdstill documented the old~/.vouch/credential location, superseded by the XDG change in Make examples reproducible, verify access tokens, and refresh dependencies #46Verification
27/27 build and smoke · web, mcp, a2a, native, claims suites = 69 passed, 0 failures ·
ruffclean🤖 Generated with Claude Code