Skip to content

feat(mcp): agent-auth consumer — assertion signing + CIMD publish (flair#623-adjacent / #159 consumer)#663

Draft
tps-flint wants to merge 2 commits into
mainfrom
feat/mcp-agent-auth-consumer
Draft

feat(mcp): agent-auth consumer — assertion signing + CIMD publish (flair#623-adjacent / #159 consumer)#663
tps-flint wants to merge 2 commits into
mainfrom
feat/mcp-agent-auth-consumer

Conversation

@tps-flint

Copy link
Copy Markdown
Contributor

Summary

Flair-side consumer for headless agent-auth to a Harper MCP /mcp endpoint (RFC 7523 client_credentials + private_key_jwt). Built against the actual plugin contracts read directly from HarperFast/oauth's merged PRs — not guessed. Two of the three pieces are fully built + tested; the third (the live token round-trip) is a deliberate, clearly-marked stub because its upstream contract isn't final yet.

Design docs: ~/ops/FLAIR-AGENT-AUTH-CONSUMER-SPEC.md, ~/ops/FLAIR-CLOUD-AGENT-BETA-ALIGNMENT.md. Full writeup: docs/notes/mcp-agent-auth-consumer.md (added in this PR).

What's built + tested

1. Assertion signing — flair mcp token (src/mcp-client-assertion.ts)

  • Builds + signs the RFC 7523 client_assertion JWT: header {alg:"EdDSA", typ:"JWT"}, claims iss=sub=client_id, aud=token endpoint, exp-iat <= 60s (hard-capped, not just defaulted), iat, random jti. Signed with node:crypto alone — no new dependency, matching this repo's existing signing style (flair-client.mjs, src/cli.ts's buildEd25519Auth).
  • Claim shape is pinned to what HarperFast/oauth PR Hash-based embedding fallback provides no semantic quality #165 (src/lib/mcp/clientAssertion.ts, merged @ d48c3b2) verifies — read directly from the PR diff. test/unit/mcp-client-assertion.test.ts includes a mirror of Hash-based embedding fallback provides no semantic quality #165's verification (alg pinning, iss/sub/aud exact match, exp/iat window, jti presence, Ed25519 signature check) and proves our assertions pass it, plus negative cases: tampered payload, wrong aud, wrong signing key, expired assertion.
  • Key loading (resolveAgentKeyPath/loadEd25519PrivateKeyFromFile) mirrors src/cli.ts's existing resolveKeyPath/buildEd25519Auth exactly — same search order (--keys-dir/FLAIR_KEY_DIR/~/.flair/keys/~/.tps/secrets/flair), same format cascade (raw seed, base64 seed, base64 PKCS8 DER, PEM fallback). No new key material.
  • CLI verified end-to-end with a throwaway test key (assertion signs, round-trips through the mirror verifier, error paths for missing key / missing issuer config both fail closed with clear messages).

2. CIMD publish — resources/MCPClientMetadata.ts

  • Serves each agent's Client ID Metadata Document at GET /MCPClientMetadata/{agentId} (public, unauthenticated — mirrors AgentCard.ts's A2A-card posture). client_id = that URL itself; jwks = the agent's existing Agent.publicKey re-expressed as a JWK OKP.
  • Chose to serve it (vs. external hosting) because Flair already publishes public agent metadata this way, CIMD is explicitly the stateless/Fabric-native registration path (no DCR row to replicate across nodes), and it keeps the agent's public key as the single source of truth.
  • Field logic (resources/mcp-client-metadata-fields.ts) is Harper-free and tested against a mirror of HarperFast/oauth PR chore: upgrade Harper to 5.0.0-beta.6 #167's validateCimdDocument (src/lib/mcp/cimd.ts, merged @ f0da8a1).

What's stubbed — pending #162

requestMcpAccessToken always throws, clearly marked pending #162. The token-endpoint grant (grant_type=client_credentials) is tracked as HarperFast/oauth issue #162 — an open issue, not yet a PR, so its exact request/response contract isn't final. buildTokenRequestForm (pure, tested) shows the shape the design docs call for; wiring a live POST now would mean guessing and re-doing it once #162 lands, so the seam is left explicit instead.

Also not in this slice (deferred, see docs/notes): flair agent register-mcp-client (the admin-gated Credential-writing command from the consumer spec) — its shape depends on open questions below — and the end-to-end/negative-path tests against a real spawned Harper (needs the live grant to exist first).

Contract questions for #162 (read directly against #167's merged code, not guessed)

Our CIMD document intentionally targets the shape our agents actually need (grant_types: ["client_credentials"], token_endpoint_auth_method: "private_key_jwt"), which today's merged #167 validator does not yet accept:

  1. clientValidator.ts's SUPPORTED_GRANT_TYPES is {authorization_code, refresh_token} — will client_credentials be added as part of docs: add custom ports note to README #162, or a separate follow-up?
  2. cimd.ts hardcodes CIMD clients to token_endpoint_auth_method === 'none' (its own comment says private_key_jwt activates with Add test coverage for CLI commands #159) — same question.
  3. redirect_uris is required + non-empty today (inherited from the DCR-shaped validator) — meaningless for a pure client_credentials agent that never does a redirect flow. We deliberately omitted it rather than invent a placeholder (which could get silently accepted onto an unintended surface if a future validator loosens without dropping this requirement) — will it become optional for private_key_jwt + client_credentials-only CIMD clients?

Fetching our document against today's deployed AS fails closed (400, missing/invalid field) rather than silently degrading to a weaker auth method — intentional interim behavior, not a bug.

Test plan

  • bun test test/unit/mcp-client-assertion.test.ts test/unit/mcp-client-metadata-fields.test.ts — 35 new tests, all pass
  • bun test test/unit — full suite, 1971 pass / 0 fail (no regressions)
  • tsc -p tsconfig.cli.json --noEmit and tsc -p tsconfig.json --noEmit — clean (one pre-existing, unrelated error in auth-middleware.ts confirmed present on origin/main before this change)
  • npm run build && npm run build:cli — production build succeeds, emits dist/mcp-client-assertion.js, dist/resources/MCPClientMetadata.js, dist/resources/mcp-client-metadata-fields.js
  • Manual CLI verification: flair mcp token with a throwaway Ed25519 test key — signs, prints the assertion + claims, correctly derives --client-id/--token-endpoint/--resource defaults from FLAIR_MCP_ISSUER, and fails closed with clear errors when the key or issuer config is missing

🤖 Generated with Claude Code

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Flair-side consumer for headless agent-auth to a Harper MCP /mcp endpoint
(RFC 7523 client_credentials + private_key_jwt), built against the ACTUAL
plugin contracts read from HarperFast/oauth PR #165 (clientAssertion.ts,
merged @ d48c3b2) and PR #167 (CIMD, merged @ f0da8a1) — not guessed.

- src/mcp-client-assertion.ts + `flair mcp token`: builds and signs the
  RFC 7523 client_assertion JWT (EdDSA, iss=sub=client_id, aud=token
  endpoint, exp-iat <=60s hard-capped, jti) using the agent's existing
  Ed25519 key. Key loading mirrors src/cli.ts's existing
  resolveKeyPath/buildEd25519Auth exactly (same search order, same format
  cascade) — no new key material, no new dependency.
- resources/MCPClientMetadata.ts + mcp-client-metadata-fields.ts: serves
  each agent's Client ID Metadata Document at GET /MCPClientMetadata/{id}
  (mirrors AgentCard.ts's public-discovery posture), JWKS = the agent's
  existing Ed25519 public key as a JWK OKP.
- The token-endpoint POST is intentionally stubbed
  (requestMcpAccessToken always throws, marked `pending #162`) — that
  grant is an open ISSUE upstream, not yet a PR, so its contract isn't
  final.

Tests mirror the actual plugin verification/validation contracts (read
from the PR diffs) rather than reimplementing them for production use.
35 new unit tests, full suite green (1971 pass), CLI verified end-to-end
with a throwaway test key.

See docs/notes/mcp-agent-auth-consumer.md for what's built, what's
stubbed pending #162, and open contract questions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jwk = agentPublicKeyToJwk(String(agent.publicKey), agentId);
} catch (err: any) {
return new Response(
JSON.stringify({ error: "invalid_agent_key", message: err?.message ?? String(err) }),
…#167 claim

oauth#161 ("client_credentials (2/4): CIMD-first client resolution") and
#162 ("client_credentials (3/4): token-endpoint grant") landed as formal,
re-scoped issue specs since #663 was opened. Tighten this consumer to match
them exactly and correct a stale inaccuracy that had crept into the code:

- Fix "PR #167 merged @ commit f0da8a1" claims across
  mcp-client-metadata-fields.ts, MCPClientMetadata.ts, src/cli.ts, and both
  test files — #167 is an open draft PR, not merged. Re-cite #161 as the
  formal CIMD shape spec our document targets.
- buildCimdDocument: add a defensive runtime guard rejecting any jwk
  carrying a private `d` component (oauth#161's "reject any key carrying
  private d"), belt-and-suspenders since the JWK crosses a runtime
  boundary even though Ed25519Jwk's TS type has no `d` field. Document that
  response_types (like redirect_uris) is deliberately never emitted for
  client_credentials-only CIMD clients — #161 blesses both omissions
  explicitly now, not just our own hedge.
- Confirm + document that buildTokenRequestForm's RFC 8707 `resource`
  pass-through (already wired via defaultMcpResource()/flair mcp token's
  CLI defaulting) matches #162's request-side scope; add a cross-check
  test proving the form's client_id equals the assertion's iss/sub.
- docs/notes/mcp-agent-auth-consumer.md: rewrite to the current oauth
  state (#160/PR165 merged, #161 open, #162 open issue, #167 open draft),
  resolve the former "open questions" section using #161's answers
  (redirect_uris/response_types omission is now blessed; the allowedHosts
  gate replaces the old DCR initialAccessToken gate), and add a deployment
  coordination note for AS operators.
- Tests: 5 new assertions (private-d rejection, response_types omission,
  non-empty/single-key jwks, form/assertion client_id cross-check,
  client_assertion_type exact value). Full suite: 1976 pass / 0 fail.

Still stubbed pending #162 landing: the live token round-trip
(requestMcpAccessToken), end-to-end tests against a real Harper, and
verifying the AS's actual discovery-metadata additions. PR stays draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tps-flint

Copy link
Copy Markdown
Contributor Author

Dependency fully published: @harperfast/oauth@2.2.0 is on npm (2026-07-12T00:07Z) and contains the complete #159 train — #170 (client_credentials + RFC7523 private_key_jwt, the shape this issue consumes) and #171 (issuance + CIMD rate limiting). Nothing upstream blocks this issue anymore.

Two consumer requirements the 2.2.0 rate limiter (#171) adds to this client, fold into the spec:

  1. Token caching is mandatory — mint sparingly and reuse until expiry. Issuance is limited per verified client_id (mcp.clientCredentials.rateLimit, default 30/min); per-request minting would burn the bucket for no reason.
  2. Handle 429 {"error":"slow_down"} on the token endpoint — honor Retry-After with jittered backoff, never hammer. (Post-auth debit means only holders of the private key can spend our identities' buckets — a forged-assertion flood cannot 429 our agents.)

🤖 Generated with Claude Code

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.

2 participants