catalora-mcp
Officialby cataloraxyz
README.md
# catalora-mcp
**Eight tools that let an AI agent trade under limits it cannot talk its way around.**
[](https://www.npmjs.com/package/@catalora/mcp)
[](https://github.com/cataloraxyz/catalora-mcp/actions/workflows/ci.yml)
[](https://modelcontextprotocol.io)
[](LICENSE)
An MCP server for trading agents. Works with Claude, Codex, Cursor, or anything else that
speaks the Model Context Protocol.
```json
{
"mcpServers": {
"catalora": {
"command": "npx",
"args": ["-y", "@catalora/mcp"],
"env": {
"CATALORA_MODE": "local",
"CATALORA_MANDATE": "./mandate.json",
"CATALORA_JOURNAL": "./fills.jsonl",
"CATALORA_MARKS": "./marks.json",
"CATALORA_STARTING_EQUITY": "100000"
}
}
}
}
```
Drop that into `claude_desktop_config.json`, `.mcp.json` for Claude Code, or the equivalent
for Codex or Cursor. Ready-made copies are in [`examples/`](examples).
---
## The eight tools
Deliberately small. An agent handed forty tools reasons worse than one handed eight — it
spends its planning budget choosing between tools instead of choosing between trades.
| Tool | Does | Why it earns a slot |
| --- | --- | --- |
| `get_mandate` | Ticker whitelist, leverage ceiling, drawdown cap, position and order limits | The agent knows its boundaries before it plans, not after it is refused |
| `get_session_state` | open / pre / post / closed / halted, plus hours to next open | The input almost no agent has, and the largest avoidable loss there is |
| `get_quote` | Reference price with a staleness flag | Tells the agent when a price is a last print rather than a live one |
| `get_portfolio` | NAV, cash, positions, headroom against every limit | Full state in one call, instead of inferring from remembered orders |
| `get_risk_budget` | Drawdown remaining, notional remaining, NAV floor | Lets the agent brake before it is stopped |
| `submit_order` | Checks the mandate, then executes. Refuses on breach | The enforcement point |
| `close_position` | Full or partial exit | Always available, even in a symbol the mandate no longer permits |
| `get_track_record` | Realised P&L, drawdown, Sharpe, hit rate, time in market | What the agent benchmarks against |
**A ninth tool needs a written argument for why the agent *reasons better* with it**, not why
the data is useful. `test/tools.test.ts` fails the build at nine. A documented promise that
nothing enforces erodes through ordinary, well-meaning pull requests.
---
## Resources and prompts — capability without a ninth tool
The obvious way to add capability would be to break the eight-tool cap. But a **tool** is for
*doing* something, and a **resource** is something you *read* that does not change when you
read it. Modelling them correctly means the surface grows and the promise holds.
**Resources** — attach to a conversation once, up front:
| URI | What |
| --- | --- |
| `catalora://mandate` | The limits as prose, written for a system prompt |
| `catalora://mandate.json` | The same, machine-readable |
| `catalora://portfolio` | NAV, positions, and headroom against every limit |
| `catalora://record` | Full track record, provenance first |
| `catalora://record/monthly` | P&L by month — the view a lucky month cannot hide in |
| `catalora://record/attribution` | How much of the record is one ticker |
| `catalora://record/drawdowns` | Every drawdown, its depth, and whether it recovered |
| `catalora://calendar` | Closures, half-days, and the next five sessions |
**Resource templates** — parameterised, read any instance:
| Template | What |
| --- | --- |
| `catalora://session/{ticker}` | Session state and whether the price is live |
| `catalora://auction/{ticker}` | Auction times and the MOC/MOO cutoffs |
| `catalora://luld/{ticker}/{price}` | Limit up/down bounds at a reference price |
| `catalora://settlement/{date}` | When a trade on that date settles |
Attaching the mandate costs nothing and means the model plans inside its limits rather than
discovering them by being refused.
**Prompts** — checklists the operator invokes by name:
- `pre_trade_check` — session, mandate, room, quote staleness, in that order. Stops at the
first step that says stop, and never submits.
- `daily_review` — what moved, what it cost, what tomorrow's calendar looks like. Explicitly
refuses to congratulate you on one good day.
- `risk_report` — every limit ranked by how close it is to binding, tightest first.
- `position_sizing` — works a size back from the **drawdown budget** rather than from NAV,
because the budget is what is actually scarce. Says "there is no room for this" when that
is the honest answer, instead of shrinking it to something pointless.
- `postmortem` — separates a good decision with a bad outcome from a bad decision, because
those need opposite responses and conflating them is how a process gets changed for no
reason. One page, no consolation.
- `record_critique` — reads the record as a sceptic. Ends with the strongest argument that it
is luck, and is told not to soften it.
---
## A REST shim over the same eight tools
For agents that are not MCP clients — a Python script, a cron job, a custom runtime.
```bash
curl -s localhost:8787/tools
curl -s -X POST localhost:8787/tools/submit_order \
-d '{"ticker":"NVDA","side":"buy","quantity":100}'
curl -s "localhost:8787/resources/catalora%3A%2F%2Fmandate"
```
It calls exactly the same `callTool` the MCP server does, so the two surfaces cannot drift.
A refusal comes back **200 with `ok: false`** — the request was correct and the answer is no;
a 4xx would tell the caller to fix a request that has nothing wrong with it.
Binds to `127.0.0.1` and has **no authentication**. That is only safe because of the binding.
---
## The kill switch
```bash
CATALORA_READ_ONLY=1
```
`submit_order` and `close_position` refuse. Everything that reads keeps working, because an
operator who has just pulled this switch will want to see where the book stands.
It **fails closed**: anything but an explicit `0`, `false`, `no` or empty counts as on. An
operator who sets `CATALORA_READ_ONLY=yes` and gets live trading because the parser wanted
`"true"` has been failed by the tool at the exact moment they were relying on it.
It disables exits too. That is deliberate — "stop everything, now" is a different intent from
"stop opening", and it needs its own way to say it.
---
## Auditing what the agent tried
```bash
CATALORA_AUDIT=./audit.jsonl
```
Off unless set. A track record says what an agent traded; the audit log says what it *tried*,
including the refusals — and an agent that submitted forty breaching orders before finding a
compliant one has told you something its P&L never will.
Arguments are recorded; results are not. A result can contain the whole portfolio, and the
arguments are the part that reflects what the agent chose.
---
## Refusals are answers, not errors
This is the part that decides whether an agent recovers or spins.
```
REFUSED — 1 mandate violation:
[POSITION_SIZE_EXCEEDED] The resulting NVDA position would be $300,000.00, 30.0% of
$1,000,000.00 NAV, above the 25.0% per-position cap ($250,000.00). At $100.00 the
largest permissible order is 500 units.
The largest order that would pass every constraint is 500 units of NVDA (submitted 3000).
```
An agent that gets `"mandate breach"` resubmits the same order. An agent that gets the
constraint, the actual value, the limit, and the exact quantity that would work fixes it on
the next turn.
Every response carries both prose and structure: the model reasons over the sentence and
computes with `structuredContent`.
When no smaller order would help, it says so — `"Do not retry it smaller."`
---
## Local mode, and what it is not
Today this server runs entirely on your machine. The mandate is a JSON file, fills go to an
append-only journal, and the track record is computed from that journal.
**`submit_order` journals a paper fill. It does not route anywhere.** Every receipt says so:
```
PAPER FILL — journalled locally at the reference price. Nothing was sent to a venue
and no capital moved. Assume zero slippage and infinite liquidity, because that is
what was simulated.
```
**A locally computed track record is self-reported.** It carries
`provenance: "local-journal"` and every response leads with the unverified notice. There is
no code path here that converts it into a verified one, and there never will be — if there
were, "verified" would stop meaning anything.
That is not a limitation to work around. It is the honest description of what a local record
is: the operator controls the input file.
### What this server refuses to fabricate
- **A price it does not have.** No marks file entry means an error, not a guess. An agent
cannot tell a made-up quote from a real one.
- **A mandate.** No mandate file means the server refuses to start the agent. An agent with
no declared limits is exactly what this exists to prevent.
- **A statistic it cannot compute.** `null`, never `0`. "Could not measure" and "measured
zero" are different facts.
- **Slippage.** A marks file is a list of prices, not a book.
---
## Chain mode
Not implemented. The CATALORA contracts ship through Robinhood, and until their ABIs are
frozen there is nothing to write against.
The stub is real, though, and it **throws rather than falling back to local mode**. An
operator who asked for settled execution and silently got paper fills would not find out
until it mattered.
The `Driver` seam exists on day one for that reason: `LocalDriver` today, `ChainDriver` when
the contracts land, same tool handlers, same schemas, same responses, one env var to switch.
No chain type or contract ABI may leak past that interface into a tool handler. Retrofitting
a seam after eight handlers have grown their own I/O is the rewrite this design avoids.
---
## Configuration
From the environment only. Never a CLI flag — flags land in shell history, and one of these
values will eventually be a key that can move money.
| Variable | Default | Meaning |
| --- | --- | --- |
| `CATALORA_MODE` | `local` | `local` or `chain` |
| `CATALORA_MANDATE` | `./mandate.json` | Path to the mandate |
| `CATALORA_JOURNAL` | `./fills.jsonl` | Path to the append-only fill journal |
| `CATALORA_MARKS` | — | JSON map of `{ "TICKER": price }`, your quote source |
| `CATALORA_STARTING_EQUITY` | — | Capital base. Without it, drawdown and Sharpe are unmeasured |
| `CATALORA_AGENT_KEY` | — | Chain mode only. Never logged, never echoed |
| `CATALORA_NETWORK` | `local` | Chain mode only |
| `CATALORA_AUDIT` | — | Append-only log of every tool call. Off unless set |
| `CATALORA_READ_ONLY` | — | Refuse every order-placing tool. Fails closed |
| `CATALORA_LOG_LEVEL` | `info` | `debug` · `info` · `warn` · `error` · `silent` |
Everything is validated at startup rather than at the first tool call. An agent that discovers
a missing key three tool calls into a plan has already wasted the plan.
The agent key is **absent** from any logged or returned config, not masked. A masked secret
still leaks its length and prefix, and a field that exists at all invites someone to widen it.
---
## stdout is the protocol stream
The single most important rule in this repository.
A stray `console.log` anywhere in the process writes into the JSON-RPC stream and corrupts it.
The failure mode is not a helpful parse error — it is a client that appears to hang, or one
that works right up until something logs mid-tool-call. This cost real debugging time on a
previous MCP server.
So:
- Everything here logs to **stderr**.
- `console.log`/`info`/`debug`/`warn`/`error`/`trace` are redirected to stderr at startup, so
even a dependency that logs cannot corrupt the stream.
- **A test spawns the real server, exercises all eight tools over a real stdio transport, and
asserts that every byte of stdout parses as a JSON-RPC message.** CI runs the same check
against the built binary.
---
## Getting started
```bash
mkdir my-agent && cd my-agent
curl -O https://raw.githubusercontent.com/cataloraxyz/catalora-mcp/main/examples/mandate.json
curl -O https://raw.githubusercontent.com/cataloraxyz/catalora-mcp/main/examples/marks.json
```
Edit `mandate.json` to the limits you actually want, point your MCP client at the directory,
and ask your agent:
> Check the session state for NVDA, then tell me what your mandate allows.
A good first sign that it is wired up correctly: ask it to buy far more than the mandate
permits and watch it come back with the exact size that would have worked.
---
## Built on
- [`@catalora/market-session`](https://github.com/cataloraxyz/market-session) — session state
- [`@catalora/mandate`](https://github.com/cataloraxyz/agent-mandate) — the enforcement rules
- [`@catalora/track-record`](https://github.com/cataloraxyz/track-record) — performance math
All three are zero-dependency and usable on their own. The MCP server is a thin surface over
them, which is why the interesting logic is tested there rather than here.
---
## Development
```bash
pnpm install
pnpm test # 147 tests, including a real end-to-end MCP session
pnpm typecheck
pnpm lint
pnpm build
pnpm dev # run from source over stdio
```
> **Note on dependencies.** `package.json` declares ordinary semver dependencies on
> `@catalora/*` and is publish-ready. Until those land on npm, `pnpm-workspace.yaml` redirects
> them to sibling checkouts, and CI checks the sibling repos out to build them. Both blocks
> are commented and have a removal trigger. This shape is what broke CI across a previous
> repo fleet, so it is scoped and temporary on purpose — the fix is publishing, not more
> scaffolding.
See [CONTRIBUTING.md](CONTRIBUTING.md).
---
## Part of CATALORA
[CATALORA](https://catalora.xyz) is the allocation layer for AI agents trading tokenized
stocks: agents trade under a mandate a contract enforces, build a track record nobody can
fake, and raise outside capital against it.
This server is the integration surface. Local mode works with none of that — no account, no
key, no contract — and the same eight tools point at the contracts when they ship.
```text
agents ──▶ @catalora/mcp ─┐
│ @catalora/mandate what an agent may do
├──▶ @catalora/market-session when it may do it
humans ──▶ @catalora/cli ─┘ @catalora/track-record what it actually did
two surfaces three zero-dependency libraries
```
The eight tools are a thin shell over the three libraries below. Nothing this server does is
unavailable to a program that imports them directly.
| Package | Repo | The question it answers |
| --- | --- | --- |
| `@catalora/mandate` | [agent-mandate](https://github.com/cataloraxyz/agent-mandate) | What is this agent allowed to do? |
| `@catalora/market-session` | [market-session](https://github.com/cataloraxyz/market-session) | Is this ticker tradeable right now, and if not, when? |
| `@catalora/track-record` | [track-record](https://github.com/cataloraxyz/track-record) | What did it actually do, and who says so? |
| **`@catalora/mcp`** | [catalora-mcp](https://github.com/cataloraxyz/catalora-mcp) | **The three libraries as eight tools, for any MCP client** ← you are here |
| `@catalora/cli` | [catalora-cli](https://github.com/cataloraxyz/catalora-cli) | The same three, from a terminal |
**Web** [catalora.xyz](https://catalora.xyz) ·
**dApp** [dapp.catalora.xyz](https://dapp.catalora.xyz) ·
**Docs** [docs.catalora.xyz](https://docs.catalora.xyz) ·
**Source** [github.com/cataloraxyz](https://github.com/cataloraxyz)
## License
[MIT](LICENSE)
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues