stellar-copilot-mcp
# stellar-copilot-mcp
MCP server for Stellar. Ask an AI assistant what an account holds, why a transaction failed, or
what a Soroban contract does — and get an answer in plain language. Optionally, propose payments
the user approves in their own wallet.
**Holds no keys. Signs nothing. Submits nothing.** Reads use public chain data. Transactions are
built unsigned and handed to a page the user controls; signing happens in Freighter and nowhere
else.
---
## Tools
| Tool | Answers |
|---|---|
| `explain_account` | "What do I hold?" · "Why can't I spend my whole balance?" · "Is my trustline set up?" |
| `diagnose_transaction` | "Why did this fail?" — decodes transaction and operation result codes into causes and fixes, and recovers Soroban contract error codes |
| `explain_contract` | "What can this contract do?" — reads a deployed contract's published interface |
Running the HTTP transport adds three more:
| Tool | Purpose |
|---|---|
| `start_pairing` | Returns a link the user opens in the browser where Freighter lives |
| `get_pairing_status` | Whether the wallet connected, and how an approval turned out |
| `propose_payment` | Builds an **unsigned** payment and sends it to the user's approval page |
The stdio binary exposes **only the three read tools**. Pairing needs a server to host the
approval page and hold session state, which stdio has neither of.
## Install
Requires Node 20+.
```bash
npm install && npm run build
```
### Claude Code
```bash
claude mcp add stellar-copilot -- node /absolute/path/to/stellar-copilot-mcp/dist/index.js
```
### Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"stellar-copilot": {
"command": "node",
"args": ["/absolute/path/to/stellar-copilot-mcp/dist/index.js"],
"env": { "STELLAR_NETWORK": "testnet" }
}
}
}
```
Cursor and other local clients take the same command; see their connector docs.
### Remote clients (ChatGPT, Gemini)
Remote clients connect over a URL rather than spawning a process, so run the HTTP transport:
```bash
npm run start:http
```
Serves MCP at `http://127.0.0.1:3000/mcp` and a health check at `/health`. Point your client at
the `/mcp` URL.
**To use it from Claude, ChatGPT, or Gemini you need a public HTTPS URL** — those clients cannot
reach localhost. A `Dockerfile` and `fly.toml` are included. See **[DEPLOY.md](DEPLOY.md)**.
## Configuration
| Variable | Default | Notes |
|---|---|---|
| `STELLAR_NETWORK` | `testnet` | `testnet` or `public` |
| `STELLAR_HORIZON_URL` | network default | Override for a private Horizon |
| `STELLAR_RPC_URL` | testnet default; **empty on mainnet** | Required on `public` — see below |
HTTP transport only:
| Variable | Default | Notes |
|---|---|---|
| `PORT` | `3000` | Listen port |
| `HOST` | `127.0.0.1` | Bind address. Keep it on loopback unless it is behind a reverse proxy. |
| `MCP_PATH` | `/mcp` | Endpoint path |
| `MCP_ALLOWED_HOSTS` | localhost variants | Comma-separated. **Required when deployed under a real hostname**, or requests are rejected with 403. |
| `MCP_ALLOWED_ORIGINS` | unset | Comma-separated browser origins, if any |
**On mainnet you must set `STELLAR_RPC_URL`.** SDF does not operate a public mainnet Soroban RPC
endpoint, so there is no sensible default. Horizon-backed tools (`explain_account`,
`diagnose_transaction`) work on mainnet without it; `explain_contract` needs it and will tell you so
rather than failing at startup.
## Try it
```bash
npm run inspect # MCP Inspector
```
Or drive it directly:
```bash
printf '%s\n%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"cli","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| node dist/index.js
```
## Development
```bash
npm run typecheck # tsc --noEmit
npm run dev # tsc --watch
npm run build # emit to dist/
```
### Two constraints to respect when adding code
**stdout is the JSON-RPC channel.** Never write to it. All logging goes to stderr — a stray
`console.log` corrupts the protocol stream, and the failure looks like a client bug.
**The HTTP transport is stateless.** No session IDs, no shared state — a fresh server per request.
Every tool is a pure read, so there is nothing to remember between calls, and stateless means no
session table to leak, expire, or scale. It also means POST is the only meaningful method: there is
no stream to open and no session to delete.
**DNS rebinding protection is on by default.** Without it, any web page the user visits could POST
to a server bound on localhost and drive its tools from the browser. Deploying under a real hostname
means adding it to `MCP_ALLOWED_HOSTS`.
**Keep this server read-only.** No key material, no signing, no submission. Transaction building and
signing belong behind an independent simulation-and-approval step in a separate deployable
(see `../TECHNICAL-SPEC.md`). The boundary is structural, not a policy note.
### Decoding contract errors
Contract-defined codes (`Error(Contract, #1205)`) mean whatever that contract's
`#[contracterror]` enum says, so they need a protocol table. Pass a `protocol` hint:
```
diagnose_transaction(hash: "...", protocol: "blend-v2-pool")
```
Known tables: `blend-v1-pool`, `blend-v2-pool`, `soroswap-pair` — see
`src/lib/contractErrorTables.ts`.
Tables are keyed by *protocol*, not contract address, because Blend pools are permissionless:
every pool is a separate deployment sharing one error enum, so an address-keyed registry would
mean enumerating every pool that will ever exist.
`KNOWN_CONTRACTS` maps verified addresses to protocols and is intentionally empty. An address
goes in only once confirmed against a published deployment list — a wrong entry would attach a
confident wrong explanation to someone's real failed transaction. Every table must cite the
source and date it was read from; a test enforces this.
### Adding a failure explanation
`src/lib/resultCodes.ts` maps XDR result-code names to a `{ meaning, fix }` pair. Add the code, write
the explanation for someone who has never read the Stellar docs, and say what to do about it.
Contract-*defined* codes are not in the result XDR, and they are not recoverable from the
historical record either: Horizon 27 returns no transaction meta at all, and public Soroban RPC
nodes leave `diagnosticEvents` empty. The server recovers them by re-simulating the call, and
says so in its output — simulation runs against *current* ledger state, so it is evidence rather
than proof of the original error.
## Status
Verified against live Stellar testnet on 30 July 2026: clean typecheck under `strict` +
`noUncheckedIndexedAccess`, working MCP handshake, correct reserve math on a Friendbot-funded account,
and correct diagnosis of a real failed Soroban contract call.
62 unit tests (including the HTTP transport and the approval page end to end) and 9 live
integration tests pass. The pairing flow, independent decoding, and injection blocking are
verified in a real browser.
**Not yet verified:** Freighter signing itself. It is a browser extension, so `signTransaction`
and the submit path need a machine with Freighter installed. Known gap: `KNOWN_CONTRACTS` is empty, so
contract errors need an explicit `protocol` hint until verified deployment addresses are added.
## Proposing transactions
Only over the HTTP transport:
```bash
PUBLIC_BASE_URL=https://your-host npm run start:http
```
The flow:
1. `start_pairing` returns a link. The user opens it where Freighter is installed.
2. The page connects the wallet and reports the address back.
3. `propose_payment` builds an **unsigned** transaction and queues it for the page.
4. **The page decodes the XDR itself** and shows what it actually does.
5. The user signs in Freighter. The page submits.
6. `get_pairing_status` reports the outcome.
### Why the page decodes it again
The assistant's description of a transaction is treated as an untrusted claim, never as truth.
If a prompt injection made the model build a malicious transaction, the model's *description* of
that transaction would be malicious too — so the only thing that catches it is comparing the
description against independently decoded reality.
When they disagree, the page shows the mismatch and **disables the approve button**. A warning a
user can click straight past is not a control.
For the same reason, **no tool returns a decoded preview to the model.** If it could read the
decode, it could misreport it, and the user would be approving the model's account of the
transaction rather than the transaction. A test asserts no such tool exists.
`PUBLIC_BASE_URL` must be an origin the user's browser can reach; it is what pairing links point
at. Sessions are in-memory, expire after 30 minutes idle, and hold no key material.
## Privacy
The server runs locally, stores nothing, and handles only public blockchain identifiers — never
keys or credentials. It queries public Stellar infrastructure (Horizon, Soroban RPC), both of which
you can repoint via environment variables. Full policy: [PRIVACY.md](PRIVACY.md).
## Licence
Apache-2.0
TDQS
Scored across 3 tools
Each tool addresses a distinct aspect of Stellar (account state, transaction outcomes, contract interfaces), with no functional overlap. An agent can reliably select the correct tool based on the user's question.
All tool names follow a consistent verb_noun pattern in snake_case (explain_account, diagnose_transaction, explain_contract). While the verbs differ, the structural pattern is uniform and predictable.
Three tools is well-scoped for a focused explanatory server. Each tool covers a core need, and the count is within the typical 3-15 range without being too heavy or too thin.
The tools cover the primary explanatory use cases for Stellar: account state, transaction diagnosis, and contract interface. Minor gaps might include explaining specific operations or network-level details, but these are not critical for the stated purpose.