The refusal grammar
Every door on this surface answers a read in exactly one of three shapes, and an agent needs exactly one branch per shape:
- The result. The data, in the standard's own envelope — an EPCISQueryDocument page for queries, a capture receipt for writes.
- The truthful empty. The same envelope with zero rows. Nothing matched, and that is the answer — status 200, isError: false, nothing to retry, nothing to apologize for. A truthful empty is never an error.
- The typed refusal. An RFC 7807 problem document carrying an epcisException:* type — the door knows why it cannot answer and says so in a machine-matchable field (the taxonomy).
One law cuts across all three: absence, not redaction. An event outside the caller's scope is indistinguishable from an event that was never captured — queries omit it, get_event answers 404. No answer shape discloses that something withheld exists.
A truthful empty, run for real
A fresh stdio MCP session (npx -y epcis.dev mcp) holds a session-local store with nothing in it; a well-formed query answers with an empty page, not an error:
tools/call query {"query":{"EQ_bizStep":["shipping"]}}
{
"structuredContent": {
"@context": [
"https://ref.gs1.org/standards/epcis/epcis-context.jsonld",
{ "spine": "https://epcis.dev/ns/spine/v1" }
],
"type": "EPCISQueryDocument",
"schemaVersion": "2.0",
"epcisBody": {
"queryResults": {
"queryName": "SimpleEventQuery",
"resultsBody": { "eventList": [] }
}
}
},
"isError": false
}
eventList: [] with isError: false — a complete, honest answer. An agent that treats this as a failure and retries is fighting the grammar. (creationDate and the content text block elided for width; every remaining byte is verbatim from the run.)
The MCP mirror: a refusal is a result
MCP transports carry refusals as tool results with isError: true, never as JSON-RPC protocol errors — the JSON-RPC layer succeeded; the door declined. The tool result body is the same problem document the HTTP doors send, so one matcher serves both transports. Cut live from POST https://epcis.dev/mcp, a keyed tool reached with no key:
{
"jsonrpc": "2.0", "id": 1,
"result": {
"structuredContent": {
"type": "epcisException:SecurityException",
"title": "Unauthorised request",
"status": 401
},
"isError": true
}
}
(The detail field — which states the exact ceremony: send Authorization: Bearer with a capture-scoped key — is shown in full on the typed exceptions.)
The branch, in one table
| the door says | it means | your move |
|---|---|---|
| envelope with rows, isError: false | here is the answer | use it |
| envelope with zero rows, isError: false | nothing matches — truthfully | accept the answer; do not retry |
| problem document, isError: true | refused, for the named typed reason | switch on type; the remediation is in the taxonomy |
| paginated result with structuredContent.nextPageToken | more rows exist | pass the token back; absence of the token is the last page |
Unknown tool names are the one exception that stays at the protocol layer: calling a tool the server never advertised is a JSON-RPC -32602 error, because there is no door to decline.
We answer in writing. We take at most five conversations a month, only when you ask for one, and only after you already have the written read.