An MCP server that gives an AI agent a verifiable identity on the XRP Ledger sounds like a small project. Derive a did:xrpl from an address, resolve it, check a credential, done.
It is not small. The DID part took a library. The part we kept rewriting was everything around it: who is allowed to spend, whose rules apply, and what happens when the thing asking for permission is the same thing that should not be trusted.
This is the part worth writing down.
What the server actually is
Twelve tools. Seven are reads, five write.
| Tool | What it does | Writes to the ledger? |
|---|---|---|
derive_did | Turn an XRP address into a did:xrpl | No |
resolve_did | Read a DID Document for a did:xrpl | No |
check_authorization | Decide whether an agent may act, with reasons | No |
lookup_account | Read an account's balance and on-ledger state | No |
lookup_transaction | Read a transaction's result and inclusion status | No |
get_credential | Read a credential's state | No |
list_credentials | List the credentials on an account, held and issued | No |
issue_credential | Create a credential for a subject | Yes |
accept_credential | Accept a credential issued to the signing account | Yes |
publish_did_set | Publish an agent's DID Document | Yes |
revoke_did | Delete an account's DID Document | Yes |
revoke_credential | Withdraw one credential | Yes |
Seven of twelve are read-only, and the five that spend XRP are the interesting half.
The first design we threw away
Our first instinct was the obvious one: the tool takes a seed argument, the agent passes the seed, the server signs with it. It is one argument and it works.
It also means the seed is in the agent's context. The model can read it, write it into a log, echo it back in a later turn. Every layer between the agent and the keychain becomes a place the key can leak, and the agent is the layer least able to be trusted with it.
The fix is to remove the seed from the tool signature entirely. The server reads it from the OS keychain itself:
def resolve_seed(inline_seed: str | None = None) -> tuple[str | None, str | None]:
"""Resolve the signing seed and say where it came from."""
seed = seed_from_keychain()
if seed:
return seed, "keychain"
if inline_seed:
if not ALLOW_INLINE_SEED:
return None, "inline-refused"
return inline_seed, "inline"
return None, None
Two details in that function are doing real work.
The keychain is tried first, always. Even when an inline seed is supplied, the keychain wins. An inline seed is a fallback, not a preference.
There is a flag, and it is off. --allow-inline-seed exists for throwaway testnet work where a seed in the conversation is harmless. Without it, an inline seed is refused outright and the server falls back to the keychain. We kept the escape hatch because a hard prohibition creates workarounds, and we made it opt-in so the workaround has to be written down.
If the keychain has nothing, the server returns NO_SEED with the setup instructions. It does not fall back to something weaker.
The five write tools refuse by default
Every write tool starts with the same check:
if not ALLOW_WRITES:
return ... # WRITES_DISABLED
--allow-writes is a startup flag, not a tool argument. The agent cannot request it. An agent that could ask for permission to spend is an agent that can spend.
This is the same reasoning as the seed, applied to a different resource. The agent's own request should not be able to widen what the agent is allowed to do.
The problem we did not expect
Here is the thing that took us the longest to get right, and it is not about keys.
The caller of a tool is not necessarily the operator of the server.
An agent that gets to choose its own trust policy will choose a permissive one. It is not malice — it is that an agent asked "may I act?" has no way to know the answer you wanted, and the path of least resistance through a tool call is the one that returns ALLOW.
So the server keeps its own policy on disk and applies it on top of whatever the caller passes:
{
"require": {
"issuers": ["rTrustedIssuer..."],
"credential_type": "agent-authorization"
},
"deny": {
"issuers": ["rRevokedIssuer..."],
"credential_type": "agent-authorization"
},
"ban": {
"agents": ["did:xrpl:1:rBanned...", "rBannedPlain..."]
}
}
Three sections, all optional:
- **
require** — the agent must hold this credential. - **
deny** — holding this credential is grounds for refusal, whatever else it holds. - **
ban** — the agent is refused outright.
The operator's rules cannot be waived by the caller. That is the entire point.
The two cases that are easy to get wrong
A missing policy file is not an empty policy. It means the operator has not expressed an opinion — it does not mean allow everything, and it certainly does not mean the caller's rules are the only rules. It means no operator rules are added to the caller's. Those are different states and they get confused constantly, usually in the permissive direction.
A policy file that exists but cannot be parsed is an error, not an absence. We surface POLICY_INVALID rather than ignoring it. Silently dropping a malformed deny list would quietly remove the one property the file exists to provide — and it would do it quietly, which is the worst way for it to fail.
A credential is not a credential until it is accepted
The credential lifecycle is five steps, and the middle of it is the part people get wrong.
A credential is identified by the triple (issuer, subject, credential type), and it takes two signatures before it counts for the subject:
| Step | Tool | Who signs |
|---|---|---|
| 1. Create | issue_credential | the issuer |
| 2. Accept | accept_credential | the subject |
| 3. Read one | get_credential | nobody — it is a read |
| 4. List all | list_credentials | nobody — it is a read |
| 5. Withdraw | revoke_credential | the issuer |
issue_credential submits a CredentialCreate. The credential exists on the ledger at that point. But XLS-70 only sets the accepted flag when the subject signs — so until accept_credential runs, the credential does not count as held.
That is why get_credential reports existence and acceptance as two separate states. A credential the issuer created and the subject never accepted is a credential that exists and grants nothing. Collapsing those two into a single "valid" boolean is the mistake, because it turns an unaccepted credential into a permission.
Revoking a DID is not revoking the credential
This one bit us, so it is worth stating plainly:
Deleting a DID does not delete the account's credentials.
A DID and a credential are separate ledger objects. A DID is keyed by its owner account. A credential is keyed by (issuer, subject, credential_type). rippled's DIDDelete erases only the DID.
So if you revoke an agent's DID and stop there, the credentials that agent holds are still on the ledger, still verifiable, and still say what they said. To withdraw the authorization, revoke the credential too.
That is not a bug in the protocol. It is a fact about the data model, and the tools make the two operations separate on purpose — revoke_did and revoke_credential exist because they revoke different things.
One more guard on the same path: DIDDelete always targets the signer's own DID, so the tool refuses with ADDRESS_MISMATCH if the address is not the one the seed controls — checked before anything is submitted, not after.
Why a DID and not a username
The identifier is derived from the address. did:xrpl:1:rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh comes from rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh.
No registration. No fee. No database row. No renewal, no expiry, no registrar to go down, no domain to lose. Resolution is a ledger_entry read — anyone can do it, and there is no server of ours in the path.
That last property is the one that matters for an identity system. A DID that resolves through our infrastructure is a DID we can be asked to censor, and also a DID we can be asked to fail to answer. A DID that resolves from the public ledger has neither property.
What this does not solve
Being explicit, because "verifiable identity for AI agents" oversells:
- It does not prove the agent is honest. It proves which key signed. What that key did with the signature is a different question.
- Credentials on XRPL are public. Anyone can read them. This is a disclosure system, not a privacy layer — bring your own key management and do not put secrets in a credential.
- It does not replace OIDC or SAML. Those solve a different problem, against a different adversary, with a different trust root. The XRPL path is for agents that need an identity that outlives any one operator.
- The operator policy is only as good as the file. It is a JSON file on the same disk the server runs on. It is a layer, not a vault.
The test we actually use
The question that settled the design of the whole server:
Can the thing asking for permission widen what it is allowed to do?
If yes, you have a tool with an allowlist the agent controls, which is not an allowlist. The seed comes out of the agent's context, the write flag comes out of the agent's reach, and the operator's rules get applied on top of the caller's, not instead of them.
The MCP server is a small wrapper around a DID library. The part worth reusing is the shape of the boundary.
*This is the design behind xrpl_did_mcp. The implementation is MIT licensed, and the verification API is public. Install it from source — it is not on PyPI yet.*