Partner MCP Spec v1
A bilateral authentication contract between AgentPress and a registered partner's MCP (Model Context Protocol) server. AgentPress signs short-lived JWTs that delegate a specific end-user's context to the partner; the partner verifies the JWT against AgentPress's public JWKS and operates on behalf of that user.
Targets MCP 2025-11-25 Streamable HTTP transport. Distinct from MCP's public-server OAuth 2.1 flow (which addresses unknown-client scenarios); this spec addresses registered-partner integrations where AgentPress and the partner have an explicit prior relationship.
Roles
- Issuer (AgentPress): holds signing keys, issues JWTs, emits rotation webhooks.
- Verifier (Partner MCP): verifies JWTs against the issuer's JWKS, resolves
subto a local user, enforces scopes, runs tool calls.
Cryptography
- Signing algorithm: EdDSA over Ed25519, per RFC 8037.
- Algorithm pinning: verifiers MUST hardcode
algorithms: ["EdDSA"]; MUST NOT trust the token'salgheader. - Key distribution: JWKS (RFC 7517) at
<issuer>/.well-known/jwks.json, derived per RFC 8414 convention. - Rotation: overlap-window model. Both retiring and new keys published simultaneously until retirement.
- Per org AND per environment: each AgentPress org has its own signing keypair, scoped per environment (staging / production). Compromise of one org's keys affects only that org. The
issclaim embeds the org slug so partners can discover the correct JWKS URL from the token.
Environments and issuers
Signing keys are per-org. The iss claim always has the shape:
https://<api-host>/orgs/<org-slug>Where <api-host> is api.agent.press (production) or stg-api.agent.press (staging), and <org-slug> identifies which AgentPress org registered the partner. The JWKS URL is always <iss>/.well-known/jwks.json.
| Env | iss pattern | JWKS URL pattern |
|---|---|---|
| Staging | https://stg-api.agent.press/orgs/<org-slug> | https://stg-api.agent.press/orgs/<org-slug>/.well-known/jwks.json |
| Production | https://api.agent.press/orgs/<org-slug> | https://api.agent.press/orgs/<org-slug>/.well-known/jwks.json |
Secret material (signing private keys, webhook HMAC secrets) is isolated per (org, environment) pair. A staging JWT presented to a partner configured for prod, or an org-A token presented to a client configured for org-B, MUST fail on issuer_mismatch at minimum (and typically also kid_missing_or_unknown, since keypairs are fully isolated).
Partners that integrate with multiple AgentPress orgs MUST run a separate verifier client per org. Do NOT accept arbitrary iss values — maintain an explicit whitelist of (orgSlug, environment) pairs you trust.
JWT claim set
Header:
{ "alg": "EdDSA", "kid": "<current kid>", "typ": "JWT" }Payload:
| Claim | Required | Type | Semantics |
|---|---|---|---|
iss | Yes | string (URL) | Exact per-org AgentPress issuer URL (e.g. https://api.agent.press/orgs/<org-slug>). Verifier MUST match against a whitelisted value; the JWKS URL is derived as <iss>/.well-known/jwks.json. |
aud | Yes | string (URL) | Partner's stable MCP URL, bound at partner registration |
sub | Yes | non-empty string | Verbatim externalAuthId returned by the bound IExternalAuthProvider |
ext_provider | Yes | string | Matches IExternalAuthProvider.name (e.g. acme) |
scope | Yes | string | Space-separated scope list, per RFC 8693 conventions |
jti | Yes | non-empty string | Unique per token; enables verifier-side replay caches |
iat | Yes | number | Unix seconds at signing |
exp | Yes | number | iat + 60 — 60-second TTL is non-negotiable |
org_id | No | string | AgentPress org identifier, for partner audit logs |
thread_id | No | string | AgentPress thread identifier, for trace correlation |
settings_id | No | string | Reserved for multi-tenant partner flows |
Verifier obligations
- Pin
algorithms: ["EdDSA"]. - Validate
iss,aud,ext_provider,exp,iat(with clock skew ±30s). - Require
kidheader; look up against JWKS; refetch JWKS on miss. - Reject missing/empty
sub,jti,scope,ext_provider. - Maintain a
jtireplay cache sized toexp - nowper entry. - Resolve
subagainst the partner's internal user record (partner-owned). - Enforce scopes per tool (partner-defined scope vocabulary).
- Return RFC 6750 errors:
401 invalid_token,403 insufficient_scope,403 user_not_provisionedwithWWW-Authenticate: Bearer error="..."headers.
Issuer obligations
- Serve JWKS at
<iss>/.well-known/jwks.jsonwithCache-Control: public, max-age=3600, CORSAccess-Control-Allow-Origin: *, no auth required. - Rotate keys on a ≤90-day cadence (or emergency on suspected compromise) with a ≥24h overlap window.
- Emit
signing_key_rotationwebhooks to registered partners at theirkeyRotationWebhookUrl, HMAC-SHA256 signed with the partner'swebhookSecret. Rotation is per-org: only partners registered with the rotating org receive the webhook. - Maintain separate keypairs per
(org, environment)pair.
Rotation webhook
POST {partner.keyRotationWebhookUrl}
Content-Type: application/json
X-AgentPress-Signature: sha256=<hex HMAC-SHA256 of raw body>
X-AgentPress-Timestamp: <unix seconds>Body:
{
"event": "signing_key_rotation",
"type": "scheduled" | "emergency",
"retiredKid": "<ULID>",
"newCurrentKid": "<ULID>",
"retiredAt": "<ISO8601>",
"effectiveAt": "<ISO8601>",
"jwksUrl": "https://{api-host}/orgs/{org-slug}/.well-known/jwks.json",
"reason": "<free text, present only when type is emergency>"
}Retry on non-2xx with exponential backoff, max 5 attempts. The webhook is an optional fast-path — partners with kid-miss JWKS refetch (the default jose behavior) still catch rotations without it, just slower.
The HMAC secret used to sign this webhook is distinct from the JWT signing key. Compromise of the HMAC secret cannot forge JWTs because the HMAC secret cannot sign EdDSA tokens.
Partner registration
Minimum fields captured per partner at registration time:
| Field | Purpose |
|---|---|
name (identifier) | e.g. acme; matches ext_provider claim |
audience | Partner's stable MCP URL, baked into aud claim |
webhookSecret | Shared OOB for HMAC-signed rotation webhooks |
keyRotationWebhookUrl | Where to POST rotation events |
allowedScopes | The scope vocabulary this partner accepts |
Error taxonomy
invalid_token— signature/claims validation failedinsufficient_scope— token valid, missing required scope for the called tooluser_not_provisioned— valid token, butsubdoes not resolve to a provisioned user on the partner sideidempotency_key_required— partner requiresIdempotency-Keyon mutating tool calls; missingidempotency_conflict— concurrent duplicate in-flight request
Scope vocabulary
Per-partner. The AgentPress org admin configures an allowlist at registration; AgentPress issues tokens with scopes that match that allowlist. Three supported allowlist forms:
- Exact —
settings:readmatches onlysettings:read. - Namespace glob —
settings:*matches any scope whose prefix issettings:(includingsettings:read,settings:write, and deeper paths likesettings:admin:users). Does NOT match the bare namespacesettings. - Full wildcard —
*matches any scope the partner requests. Use for trusted partners where the admin vouches for the integration as a whole.
Each signed JWT still carries a single concrete scope in the scope claim — drawn from the partner's _meta.requiredScope tool annotation — so verifiers do not need to parse or expand globs. Allowlist matching is performed exclusively on AgentPress's signing side.
Admins opting into * or settings:* are accepting that future scopes the partner adds under that prefix will be auto-approved without re-review.
Transport
MCP 2025-11-25 Streamable HTTP; both stateless and session modes supported. Stateless is recommended — matches the short-lived JWT model.
Out of scope
- User resolution, tool dispatch, idempotency cache, rate limiting, tool-level audit logs — all partner-domain.
- Refresh tokens — intentionally omitted; 60s TTL + fresh sign-per-request is the model.
- Authorization server discovery — partners are pre-registered, not discovered.
SDK helpers
@agentpress/sdk ships matching helpers so partners don't hand-roll verification:
import { AgentPress } from "@agentpress/sdk";
const client = new AgentPress({
webhookSecret: process.env.AGENTPRESS_WEBHOOK_SECRET,
partnerMcp: {
jwksUrl: "https://api.agent.press/orgs/<org-slug>/.well-known/jwks.json",
issuer: "https://api.agent.press/orgs/<org-slug>",
audience: "https://mcp.partner.example/v1",
expectedExtProvider: "acme",
},
});
// Inbound JWT verification:
const claims = await client.partners.verifyToken(bearerToken);
// Key rotation webhook:
const event = client.webhooks.verifyKeyRotation({ payload, headers });
await client.partners.refreshJwks();See the SDK README for the full API surface and error hierarchy.