Whether a call succeeds is decided by four gates, in this order. They are independent: clearing one says nothing about the next.
The most common mistake is assuming a credential is enough. It clears gate 2. Gates 3 and 4 still apply, and on a blocklet that has declared no network rules they will still refuse a write from an owner-role caller.
| # | Gate | Decided by | Failure looks like |
|---|---|---|---|
| 1 | Method allowlist | Whether the JSON-RPC method is anonymous-safe | 401 + WWW-Authenticate |
| 2 | Credential | The bearer token or session cookie you sent | 401 + WWW-Authenticate |
| 3 | Path policy | What the blocklet declared for that path | 200 with isError and AFS_FORBIDDEN |
| 4 | Provider capability | What the provider behind the path implements | 200 with isError and a not-supported error |
Gates 1 and 2 are transport-level: they answer with an HTTP status and no JSON-RPC result. Gates 3 and 4 are inside a successful MCP call: the call itself worked, the operation did not. See Errors.
Why there are four
Because three different parties decide them, and none can answer for another.
| Gate | Decided by | What it is really asking |
|---|---|---|
| 1, 2 | The runtime | Is this method safe without a caller, and is this caller who they say they are? |
| 3 | The blocklet | Have I opened this path to the network? |
| 4 | The provider | Do I implement this operation at all? |
This is why a credential is not enough, and why no better credential would help. A credential is an answer to the runtime, and the runtime does not own gates 3 and 4. No token can speak for the blocklet, and none can add an operation the provider never implemented.
It also explains the failure shapes. Gates 1 and 2 are decided before a tool is reached, so they can only answer in HTTP. Gates 3 and 4 are decided by code that already ran, so they answer inside a 200.
Gate 1: the method allowlist
Without a credential, only these are allowed:
| Allowed anonymously | Not allowed anonymously |
|---|---|
initialize, notifications/initialized | tools/call with afs_write, afs_delete, afs_exec |
tools/list, resources/list, prompts/list | Any other method, including unknown ones |
tools/call with afs_read, afs_list, afs_search, afs_stat, afs_explain, search_content, list_content, get_content |
The rule is fail-closed: anything not on the list is refused, and in a batch every message must be allowed or the whole batch is refused.
Gate 2: the credential
Three ways to clear it:
| Tier | Credential | Where it comes from |
|---|---|---|
| Anonymous | none | n/a |
| Authenticated | Authorization: Bearer blocklet-… or a DID-Connect session cookie | Authorize a client or the device grant |
| Loopback operator | verified same-machine socket peer, Node runtime only | Nothing to obtain. Never available over the network; the Cloudflare runtime never grants it |
Roles
When a person approves a client, the consent screen asks for an access level. Four are offered:
| Role | |
|---|---|
owner | the default on the consent screen |
admin | |
member | |
guest |
The credential carries the role the person picked, scoped to that blocklet instance. It is not a global role: the same person can be owner on one blocklet and have no access on another.
The role is an upper bound, not a grant. It cannot open a path that gate 3 has closed.
Gate 3: the path policy
This is the gate most callers hit and the one no credential can bypass.
A blocklet decides, per path, what a network client may do. Paths that were not declared are closed to the network, including to an owner. The refusal is explicit about the fix:
{
"result": {
"content": [{
"type": "text",
"text": "AFS_FORBIDDEN: Forbidden at /instance/notes.txt: network clients cannot write base paths; use a resolver overlay or internal code"
}],
"isError": true
},
"jsonrpc": "2.0",
"id": 3
}Read and list produce the same shape with cannot read base paths / cannot list base paths and declare a networkRead rule or use an overlay.
What the declaration changes, compared across two blocklets:
| Call | Blocklet that declares nothing | Blocklet that declares collections with readRole: guest |
|---|---|---|
anonymous afs_list / | refused | 200 |
anonymous afs_list /packages | refused | 200 |
anonymous afs_list /.knowledge | refused | refused, because that path was not declared either |
anonymous list_content on a declared collection | tool not registered | 200 |
owner-credential afs_write on any base path | refused | refused |
The last row is the one to remember. Writing from the network is not something a credential turns on; it needs a resolver overlay or code running inside the blocklet. If you are building an agent that must write, that is a decision for whoever owns the blocklet. See Declare what agents see.
Gate 4: provider capabilities
Each path is served by a provider that implements only some operations. afs_stat reports both what the provider can do and what you are allowed to do:
{
"capabilities": ["list", "read", "stat", "search", "write", "delete", "exec", "explain"],
"accessMode": "readonly"
}capabilities is the provider's implementation. accessMode is your effective access after gate 3. A path can list write in capabilities and still be readonly for you.
Check capabilities before making an operation part of an integration. See AFS for the contract these operations belong to.
What this means in practice
- Reading published content works without any credential when the blocklet declared it. That is the intended path for most agents, and it is what the content tools are for.
- Getting a credential is worth doing when you need a specific person's view of the data, or when the blocklet has opened write paths to the network.
- A credential is not a write switch. If writes are refused after you authenticate, gate 3 is the reason and the blocklet owner is the only one who can change it.