Poppy compliance
Every Personal Agent Protocol requirement the shop meets, and the test that proves it.
The conformance suite runs at HTTP level against the real Worker entry (handleRequest). The tests are written from
the text of the protocol (draft 0.1), not from recorded output. The OpenAPI document is /poppy/openapi.json and the
Poppy operations are the /v1 procedures (POPPY_PATHS in apps/api/src/poppy/conformance.ts). Passing tests show
local behaviour, not a deployed instance’s.
The Personal Agent Protocol draft 0.1 is the normative text.
Start with the Poppy overview for Sessions, grants and the supported sign-in channels. MUST rows can fail the gate; SHOULD and MAY rows record
what the shop chose. The tables below preserve the requirement row IDs alongside the conformance suites that verify them.
Where the tests are
| Surface | Suite |
|---|---|
| Sessions, tokens and APIs (A) | apps/api/src/poppy/conformance.tokens.test.ts, conformance.authority.test.ts, conformance.document.test.ts |
| Conversations (C) | apps/api/src/poppy/conformance.conversations.test.ts, conformance.retries.test.ts, conformance.authority.test.ts |
| MCP (M) | apps/mcp/src/app.conformance.test.ts, infra/alchemy/composition.test.ts, the Auth0 proxy discovery tests |
| End to end | apps/webshop/e2e/mcp-api.e2e.ts, conversations-api.e2e.ts, poppy-direct.e2e.ts |
Sessions, tokens and APIs (spec 4 and 6)
| Row | Level | Requirement | How it is met |
|---|---|---|---|
| A1 | MUST | The openapi API points to an OpenAPI 3.0 or 3.1 description |
3.1.1 view of the contract (/poppy/openapi.json), dpop security per operation, no QUERY operations, every reference resolves |
| A2 | MUST | Every listed API accepts Session Tokens | Each operation of the document, including the checkout reads at /v1/checkout-previews, /v1/me/checkout-address and /v1/me/checkout-methods, succeeds with its declared status (201, 202, 204 or 200) |
| A3 | MUST | API and conversation calls carry Authorization: DPoP and a proof |
Missing proof is 401 invalid_dpop_proof on a REST and a conversation endpoint |
| A4 | MUST | Signature, key, htm/htu, iat, jti not reused, ath |
One test per check; jti replay is refused by another handler through the D1 record |
| A5 | MAY/MUST | Nonces: use_dpop_nonce |
Stale nonce gets a fresh one; the retry succeeds |
| A6 | MUST | Bearer Session Tokens only on MCP | A Bearer Session Token is 401 invalid_token with a DPoP challenge |
| A7 | MUST | Token in Authorization, never from a URL or cookie |
?access_token= and cookies alone are 401 |
| A8 | MUST | Only tokens issued for the service | A token for the MCP audience is 401 on the API |
| A9 | MUST | A narrower token bounds what it can do on a broader Session | Effective scopes token ∩ Session on REST, MCP and the Company Agent |
| A10 | MUST | 429 with Retry-After on any endpoint |
Transport limit on REST and conversations; the message limit adds RateLimit headers |
| A11 | MUST | poppy:read and poppy:write are independent |
Each alone is refused the other’s operations on REST and MCP |
| A12 | MUST | A signed-out token for a signed-in Session is handled signed out | Account action is 403 sign_in_required |
| A13 | SHOULD | The Session is checked on each request | After sign-out a write is 403 sign_in_required at once |
| A14 | MUST | invalid_token 401, sign_in_required 403, insufficient_scope 403 naming account scopes |
Missing, expired and malformed tokens are 401 in the DPoP scheme; the scope names poppy:write |
Conversations (spec 7)
| Row | Level | Requirement | How it is met |
|---|---|---|---|
| C1 | MUST | Paths and methods under the endpoint | Each answers its status; other methods are refused with a JSON error |
| C2 | MUST | Other client, User or account, and unknown IDs, give the same error | Byte-identical conversation_not_found, also for the same User signed in to another account |
| C3, C4 | MUST/SHOULD | IDs are 1 to 256 of [A-Za-z0-9_-], with a kind prefix |
400 for bad message IDs; cnv_, evt_, msg_ |
| C5 | MUST | 201 on start, 202 on later messages, with all three fields | Also for a Direct Conversation |
| C6 | MUST | A retried message returns the original response | Message retries; a retry gets the stored response even after its cursor was pruned |
| C7 | MUST | wait and cursor on either POST |
A read response with the 201 or 202 status |
| C8-C11 | MUST | Message and event fields | 400 with invalid_request and issues; context merges; Company messages carry no context |
| C12-C14 | MUST/MAY | Reads carry cursor, has_more, status, responder; invalid_cursor, cursor_expired |
Also for streams; wait is capped at 30 seconds |
| C15-C18 | MAY/MUST | Server-sent events (oRPC’s native event iterator), Last-Event-ID, closing a stream, no text-delta |
A WHATWG parser sees id and the default event type only; a reader that loses access is closed with no event: error, and its reconnect gets the challenge |
| C19-C21 | MUST | status and responder, sender, handoff |
Handoff is queued then idle, with Fern explaining |
| C22-C24 | MUST | Direct Conversations | Errors, direct_opened and direct_closed, cascade on close |
| C25-C27 | MUST | Sign-in during a conversation; token switches | Authorization events; every request makes its token Fern’s; per-reader streams |
| C28 | MUST | Closing | 200, a closed state event, events stay readable |
| C29 | MUST | Non-token errors are JSON with an error code |
The seven codes with their statuses |
| X1 | MUST NOT | No operations unless the extension is listed | No response schema has an operation property |
MCP (spec 6)
| Row | Level | Requirement | How it is met |
|---|---|---|---|
| M1 | MUST | Streamable HTTP, 2025-06-18 or later | initialize negotiates it |
| M2 | MUST | Protected resource metadata names auth.issuer |
POPPY_AUTH_ISSUER, checked against the Auth0 proxy issuer when the stack is composed, next to the tenant issuer |
| M3 | MUST | An ordinary MCP client can sign in with MCP’s own authorization flow | Two token families: standard OAuth tokens (DCR, CIMD) and Poppy Session tokens |
| M4 | MUST | Session Tokens are accepted as Bearer tokens and their scopes enforced | Signed in and signed out |
| M5 | MUST | insufficient_scope names the account scopes |
For a Poppy token scope="poppy:write", never orders:write; a standard token is told the OAuth scope |
| M6 | MUST | sign_in_required and insufficient_scope are Bearer 403s |
With the resource metadata URL |
| M7 | MUST | Only tokens issued for the server; Bearer only | A token with cnf.jkt or act.poppy_jkt is refused |
| M8 | SHOULD | Don’t forward a Session Token to another internal service | Not met: Poppy-Subject carries it to the Shop API, which verifies it again |
The canonical MCP server accepts two kinds of token:
- Standard MCP OAuth. Any client registered by Dynamic Client Registration or a Client ID Metadata Document gets a plain Auth0 token for the MCP resource, with no Poppy Session. The server treats it as a delegated caller: On-Behalf-Of exchange for the API, the account cart, agent scopes only. A refusal names OAuth scopes.
- Poppy. A Session-backed token follows the Poppy rules: effective scopes are the token’s scopes intersected with the live Session’s, the Session is introspected, and a refusal names account scopes.
The Poppy rules apply only to Poppy tokens. The protected resource metadata lists auth.issuer and the tenant issuer
(once, when they are the same login domain).