114 typed tools, one drop-in
http://127.0.0.1:8420/mcp is the same port REST and the socket answer on. Tools and REST routes are the same surface counted two ways, so an agent and a script are looking at exactly the same engine. Four lines of configuration and your model can read the market.
45 global · 23 × 2 · 23 venue
stateless Streamable HTTP, tools only
On this page
Overview
One tool per REST capability
Tools and REST routes are the same surface: every tool has a route behind it. What that is not is a matching count: one REST path can be many named tools, and a few capabilities are deliberately one-sided.
The registry is 45 global tools, 23 market-typed tools minted once for spot and once for futures, and 23 venue operations — 114 in total. Market-typed tools are named {spot|futures}_{tool}, so a model never has to remember to pass a market type and can never pass the wrong one.
Stateless by design. Every call stands on its own there is no session to establish, resume or lose, and nothing to clean up after a client goes away. Point as many clients at it as you like; they cannot interfere with each other.
More tools than routes. 114 tools sit over 104 routes, and the difference is almost entirely one path: POST /api/{marketType}/exchange/{method} is a single route per market type and 23 separately named tools, because a model picks a tool by name and should never have to assemble one out of a parameter.
A couple of capabilities run the other way. get_agent_capabilities mirrors GET /api/agent/capabilities exactly, and it is the one tool no narrowing can take away. delete_backtest does not exist at all: permanently removing a stored backtest report is a REST-only operation. cancel_backtest stops a running job and is idempotent, but it never removes one.
Most of the surface is modular. 45 of the 114 tools are registered by the engine’s 5 feature modules. A module that is switched off in a build does not register its tools at all, which is the same absence a narrowed scope produces. The Modules group in the contents documents them one at a time.
Every tool carries a full description and a typed input schema, which is how a model learns the surface without you writing a prompt about it. The list your engine returns from tools/list is the authoritative one this page names the tools, and the engine ships their schemas.
Connect
Four lines
Drop this into your client's MCP configuration. There is no API key to paste, no OAuth flow and no hosted endpoint the server is the engine already running on your machine.
{ "mcpServers": { "pricesylo": { "type": "http", "url": "http://127.0.0.1:8420/mcp" } }}The server key is pricesylo, the transport is http, and the path is /mcp. Anything that speaks Streamable HTTP MCP works; nothing is client-specific.
Not in the demo build. The demo serves REST and the WebSocket only. Its MCP server is never mounted and it ships no command line either, so the configuration above will simply fail to connect against a demo engine. Everything on this page needs a licensed build.
Security boundary
The bind address is the security boundary
There is no account, no signup, no API key and no session. The engine is a process on your machine, and it binds to 127.0.0.1:8420. Anything that can reach the port has all 114 tools, including the 23 venue operations and the subscription tools.
Treat the loopback interface as the whole of the access-control model. Do not move the bind to 0.0.0.0, onto a LAN interface, or behind a public reverse proxy. If you need the surface from another machine, tunnel it — ssh -L rather than exposing it.
Browsers get a narrower door than curl does. A request that carries an Origin header has to name an allowed origin, so a page you happen to have open in another tab is not a working client for your engine. A request with no origin curl, a server-side job, the MCP surface is unaffected. The engine can also be started behind a shared token, in which case every request carries it as X-Engine-Token and an unauthenticated call answers 401 unauthorized.
What is never returned
Exchange credential values are masked wherever configuration is echoed, and anything an exchange sends back is scrubbed before it reaches a response, a log line or an error message. A webhook signing secret is write-only in the strictest sense: no surface returns it, nothing logs it, and a subscription record reports only whether one is set.
That is authentication, and there is deliberately none of it. Authorization is a different question and it does have an answer: scopes narrow what a client is allowed to reach, and 2 of those scopes will not act at all until the call says so explicitly. An engine on a machine you share with anyone should be narrowed by the header before it is trusted by the network.
Scopes and confirmation
19 capability scopes, 2 behind a confirmation
The engine grants every scope by default. A client narrows its own grant it can never widen one and what it narrowed to is exactly what the tool list will show it.
Narrowing is one header. X-PS-Scopes on the POST /mcp request carries a comma-separated list of the scopes this client is willing to use. The engine intersects it with the grant the build already carries, and the intersection is final for that request. An empty list is legal, and leaves an agent with nothing but the capability probe.
A denied tool is absent, not refused. This is the difference that matters on MCP. REST answers a scope failure with a refusal you have to handle; MCP simply never registers the tool, so it does not appear in tools/list and a model never learns the name of something it is not allowed to call. Narrow the scopes and the surface shrinks in front of the model rather than failing underneath it.
The scope vocabulary
2 scopes need a second signal. exchange.trade and orders.write are behind a confirmation gate. A call that needs it carries "_meta": { "autoApprove": true } in the tools/call parameters, or the request carries an X-PS-Confirmed: 1 header. Without one of the two the tool answers with a refusal instead of acting. The scope check runs first, so a scope you never asked for cannot be confirmed into existence.
In practice the gate covers the venue operations marked as moving value in the Venue operations section, plus {spot|futures}_refresh_orders and {spot|futures}_clear_order_history. No module tool is gated at all: creating an indicator, saving a strategy, queueing a backtest and writing a journal entry each need their own write scope and nothing more.
Every tool is annotated. Annotations follow the scope a tool sits on. A tool on a read scope is marked readOnlyHint ; a tool that trades, writes orders or edits the favorites list is marked destructiveHint, as is anything whose name deletes, removes, clears or cancels. A client that surfaces annotations can therefore ask a human before a destructive call without knowing anything at all about this engine.
Discovery is unscoped. get_agent_capabilities is the one tool narrowing can never remove, and it mirrors GET /api/agent/capabilities. It answers with the whole scope vocabulary and, per scope, whether this client currently holds it, whether it is gated, and which tools and routes it covers plus the confirmation contract, the socket contract, where narrowing is read from, and the modules this build loaded. An agent that has just narrowed itself calls this to find out what it has left.
curl -s -X POST http://127.0.0.1:8420/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'X-PS-Scopes: market.read,context.read,indicators.read' \ -d '{"jsonrpc":"2.0","id":5,"method":"tools/call", "params":{"name":"get_agent_capabilities","arguments":{}}}'{ "jsonrpc": "2.0", "id": 5, "result": { "content": [ { "type": "text", "text": "…" } ], "structuredContent": { … } } }Handshake
Plain JSON-RPC over POST
Your client does this for you. It is here because being able to reproduce it with curl is the fastest way to tell a configuration problem from an engine problem.
curl -s -X POST http://127.0.0.1:8420/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize", "params":{"protocolVersion":"2025-06-18","capabilities":{}, "clientInfo":{"name":"my-client","version":"1.0.0"}}}'{ "jsonrpc": "2.0", "id": 1, "result": { … } }Result shape
A text block, plus the payload as structured content
Every tool answers the same way, so a client that can read one can read all 114.
- Text plus structure. The result carries a text content block and
structuredContent. They hold the same payload the text block for a model that reads text, the structured field for a client that wants the object. A payload that is not an object is wrapped, because the protocol requires an object there. - Charts answer with a picture.
spot_get_chart_screenshotandfutures_get_chart_screenshotreturn an image content block —image/png, base64, nodata:prefix followed by a text block carrying the same render metadata, with the image bytes stripped out of it. Read the image block for pixels and the structured field for everything else. - Failures are tool errors. A refusal comes back as an error result with a message, not as a transport failure. Anything an exchange echoed back is scrubbed before it reaches you.
Not implemented
Stated so you do not go looking
This server implements tools. That is the whole surface, and the omissions are deliberate rather than pending.
| Capability | Status |
|---|---|
| Tools | All 114 |
| Resources | None |
| Prompts | None |
| Sampling | None |
| Sessions | Stateless |
| Authentication | None loopback bind |
Tool failures
Named, enumerated, never silently fixed
The same contract REST gives you, in the shape MCP expects.
Errors name the field and enumerate the valid set
Nothing is silently substituted, clamped or ignored. A parameter outside its allowed set is refused, and the refusal carries the offending field name followed by every value that would have worked. A typo in a timeframe, a style, a read key or an operator is therefore one request away from an answer, and you never need a hardcoded copy of an enum to write a correct client.
The same discipline applies to defaults: omit an optional parameter and the engine applies its own configured value. Send a bad one and it tells you. It will not quietly pick something near what you asked for.
One difference is worth spelling out. The engine’s 5 feature modules name their failures in upper snake case — STRATEGY_NOT_FOUND, BACKTEST_JOB_NOT_FOUND, CUSTOM_INDICATOR_INVALID while the core surface names its own in lower snake case. Both arrive the same way and both name the thing you got wrong; only the spelling differs.
A module failure can also carry more than a message. A validation refusal attaches an errors list with one entry per problem, each naming the path it sits on, and some attach a data object with the specifics. Where a refusal is expected rather than exceptional an invalid custom indicator, an invalid strategy the answer arrives as ordinary tool DATA with a false ok flag and that list, not as an error result, so a model can read the problems and try again without unwinding anything.
Core global tools
11 tools
Not market-typed. Engine state, the live configuration, and the whole subscription surface a rule string carries its own market type, so a spot_ or futures_ prefix would be a second and contradictable source of truth.
| Tool | Inputs | What it does |
|---|---|---|
engine_health | None | Liveness and uptime. Touches nothing but the clock, so it answers even while the engine is busy |
engine_status | None | Runtime snapshot: uptime, tracked pairs, live streams, cache readiness, memory, and the effective configuration with credentials masked. Call this first to confirm the engine is up |
list_platforms | None | The eight exchanges and their cached spot and futures pair counts. The platform ids here are the only values every other tool accepts |
get_config | None | The effective configuration, credentials masked. Read-only |
create_subscription | rule, url, secret?, confirm?, cooldownMs?, rearm?, enabled?, label? | Create a webhook subscription. Its description teaches the whole rule grammar with worked examples, because a model has no other way to learn it |
list_subscriptions | None | Every subscription, enabled or not. Never fails; empty is an empty list |
get_subscription | id | One record. Poll this after creating a rule to confirm it is actually sampling |
update_subscription | id, enabled | Enable or disable. enabled is the only mutable field; enabling resets the trigger state |
delete_subscription | id | Delete permanently. Events it already wrote to the log survive |
test_subscription | id | Send one signed test delivery now. Succeeds even when the delivery fails read the result, not the absence of an error |
list_events | since?, limit? | Replay the durable event log from a cursor |
The capability probe
| Tool | Inputs | What it does |
|---|---|---|
get_agent_capabilities | None | Global, read-only and the only tool no scope can remove: it reports the scope vocabulary, which of those scopes this client currently holds, which are gated behind a confirmation, and which modules this build loaded. An agent that has narrowed itself calls this to discover what it has left |
Core market-typed tools
17 shapes × spot and futures
Each name below exists twice, prefixed spot_ or futures_. The pair of names is why a model never has to reason about market type as a parameter.
| Tool suffix | Inputs | What it does |
|---|---|---|
search_pairs | platform?, query?, limit? | Search the local pair catalogue |
list_favorites | None | The pairs this engine is actively tracking |
add_favorite | platform, symbol, timeframes?, tier? | Start tracking a pair. Nothing is tracked until you ask |
remove_favorite | platform, symbol | Stop tracking a pair and release its streams |
get_compiled_context | platform, symbol, include? | The compiled, price-stamped context for one pair. include takes one half: kline or orderflow |
get_kline_context | platform, symbol, timeframe, section? | The structural analysis for one timeframe all sixteen reads, or one of them with section |
get_orderflow_context | platform, symbol, section | One live orderflow read |
get_chart_screenshot | platform, symbol, timeframe, style?, theme?, indicators?, width?, height?, bars?, showVolume? | A PNG chart, returned as an image content block. Works on any catalogued pair, favorite or not |
get_klines | platform, symbol, timeframe?, limit?, before? | Raw OHLCV bars, oldest first. Also works on a pair that is not a favorite |
get_recent_trades | platform, symbol, limit? | Executed prints, oldest first |
get_depth | platform, symbol, levels? | An order-book snapshot |
get_depth_history | platform, symbol, limit?, before? | Recorded book snapshots, oldest first |
get_footprint | platform, symbol, limit?, before? | The one-minute bid × ask footprint, oldest first |
list_orders | platform?, symbol?, limit? | Working orders the engine is tracking |
get_order_history | before?, limit?, query? | Completed orders, newest first |
refresh_orders | platform, symbol?, apiKey?, apiSecret?, apiPhrase? | Re-poll the venue for the current state of tracked orders. The only order tool that reaches a venue the other three read records this engine already holds |
clear_order_history | None | Clear the local order history. The exchange is not touched |
Context tools serve tracked pairs only, and answer thinly for the first seconds after add_favorite while the pair warms up. The two exceptions are get_chart_screenshot and get_klines: both can backfill, so both work on any catalogued pair.
Read keys
Read keys are enumerated by the engine, live
A pair carries sixteen structural reads per tracked timeframe and eight live orderflow reads. Each is addressable on its own, and every parameter spelled section takes one of those keys.
This page does not print the key list, because the engine already publishes it and a copy here would be the stale one. To see the keys a pair exposes right now, call the compiled-context tool, or send a deliberate typo and read the tool error. Both answers come from the running engine, so they are correct for the version you actually have.
Venue operations
23 tools
Named {mt}_{operation}, one per allowlisted venue operation. One vocabulary across all eight exchanges the engine normalises what comes back, so a model does not learn eight dialects.
Mutating tools say so, in their own description
Tools marked ⚠ act on a live account whenever credentials are present placing or cancelling orders, moving funds, or changing leverage, margin and position mode for every order that comes after and every one of them carries an explicit warning inside the description the model reads: never call this to explore or test, only on a specific instruction. A read that times out may be retried; a mutation that times out never is.
Every tool that authenticates takes apiKey, apiSecret and apiPhrase as optional inputs, overriding whatever the engine already holds for that call alone. The operations that reach no credential at all do not carry those fields, so a model cannot even offer them. Values are never echoed back and are stripped out of any error a venue returns. platform is always required.
These are also the tools behind the confirmation gate. Every operation marked below sits on the exchange.trade scope, which means a call must say so explicitly before it will act — the flag and the header are documented under Scopes. Drop that scope from the header and the whole marked half of this list disappears from the tool list.
spot_ 7 tools
futures_ 16 tools
Parameter names are identical to the REST passthrough — the full parameter list is on the REST page. A dotted chip marks an operation that moves value.
Indicator tools
17 tools from the analytics module
Read the built-in indicator catalogue, compute any of it over a pair, run the engine’s structural and orderflow analysis, and write indicators of your own in the engine’s formula language.
33 indicators ship built in, filed under 7 categories. Anything you write yourself lands in the first of them and is indistinguishable from the rest afterwards: it lists, it computes by id, and the strategy validator resolves it.
| Tool | Inputs | Scope | What it does |
|---|---|---|---|
list_indicators | query?, category?, ids? | indicators.read | The catalogue, filterable by free text, by category, or by an explicit list of ids. Indicators you wrote appear here too, under the custom category |
get_indicator | id | indicators.read | One catalogue entry, including its parameters and what each of its outputs means |
get_custom_indicator_guide | None | indicators.read | The whole formula language in one call: the grammar, the bar fields, the series functions, the callable built-ins, worked examples and the hard limits. A model reads this before it writes its first definition |
list_custom_indicators | None | indicators.read | Every custom indicator this engine has stored |
get_custom_indicator | id | indicators.read | One stored definition |
validate_custom_indicator | definition | indicators.read | Parse and type-check a definition without storing it. Answers with the normalised definition and one entry per problem, each naming the path it sits on |
create_custom_indicator | definition | indicators.writewrites | Store a definition. The id must start cx_ and is derived from the name when you leave it out |
update_custom_indicator | id, definition | indicators.writewrites | Merge changes into a stored definition and bump its version. Parameters and outputs are replaced whole rather than merged into |
delete_custom_indicator | id | indicators.writewrites | Remove a stored definition. Refused while a saved strategy still references it |
{spot|futures}_preview_custom_indicator | symbol, platform?, timeframe, bars?, definition, params? | indicators.read | Compute an unsaved definition against a tracked pair and hand back a per-output summary. This is the step between validating a definition and committing to it |
{spot|futures}_compute_indicators | symbol, platform?, timeframe, limit?, indicators | indicators.read | Compute a batch of indicators over one pair in a single pass, on one shared time axis. An entry may name a higher timeframe than the request and is aligned down to it; a lower one is refused |
{spot|futures}_get_structure | symbol, platform?, timeframe, limit?, left?, right?, lookback?, atrMult?, atrPeriod?, tolerancePct? | structure.read | The structural analysis for one timeframe, or one read of it by name. The engine enumerates the reads it exposes |
{spot|futures}_get_orderflow_metrics | symbol, platform?, levels?, obiDepth? | structure.read | Live book-derived metrics for one pair. A metric the book cannot support yet comes back marked unavailable with a reason, rather than as a number you should not have trusted |
The formula language. 41 series functions run over 10 bar fields, and all 33 built-ins are callable from inside a formula. A formula is never evaluated as source, and every one of them is bounded: no loops, no recursion, constant windows, and a null that propagates rather than a number that lies. Ids are namespaced cx_, and one custom indicator cannot call another.
The lifecycle is guide, validate, preview, store. get_custom_indicator_guide teaches the language, validate_custom_indicator type-checks a draft, {spot|futures}_preview_custom_indicator runs it against a real pair so you can look at the numbers, and create_custom_indicator stores it. Skipping the middle two is allowed and is how most first attempts fail.
Invalid definitions come back as data. This is deliberate and it is the one place this server bends the usual rule. Creating or updating an indicator the engine cannot compile answers with a normal result carrying a false ok flag and an errors list, not with a tool error so a model reads what was wrong, fixes it and calls again inside the same turn.
curl -s -X POST http://127.0.0.1:8420/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'X-PS-Scopes: indicators.read,indicators.write' \ -d '{"jsonrpc":"2.0","id":7,"method":"tools/call", "params":{"name":"create_custom_indicator","arguments":{"definition":{ "id":"cx_trend_pressure", "name":"Trend Pressure", "params":[{"key":"len","type":"int","default":20,"min":2,"max":200}], "outputs":[ {"key":"bias","formula":"ema(close, len) - ema(close, len * 3)", "kind":"line","pane":"sub","anchor":"none","side":"neutral"}, {"key":"hot","formula":"bias > 0 and rsi(14) > 60", "kind":"marker","pane":"main","anchor":"high","side":"bull"}], "tags":["trend"]}}}}'{ "jsonrpc": "2.0", "id": 7, "result": { "content": [ { "type": "text", "text": "…" } ], "structuredContent": { "ok": false, "errors": [ … ] } } }Strategy tools
10 tools, none of them market-typed
A strategy is a declarative document market, inputs, entry, filters, exit, risk that the engine validates, versions, and can explain back to you in prose. Every write produces a new version; nothing is ever edited in place.
| Tool | Inputs | Scope | What it does |
|---|---|---|---|
list_strategies | None | strategy.read | Every saved strategy, with its current version and the list of versions behind it |
get_strategy | id, version? | strategy.read | One strategy, at its current version or at any earlier one |
create_strategy | definition | strategy.writewrites | Save a definition as its first version. An invalid definition is refused, and the refusal names every path that failed |
refine_strategy | id, ops, changeSummary? | strategy.writewrites | Apply a list of operations to a saved strategy. The result is a new version that records its parent, what changed, and a summary the engine generates for you |
validate_strategy | definition | strategy.read | Check a draft without saving it: errors, warnings, the indicator references it resolves to, and how many bars it needs before it can trade |
delete_strategy | id | strategy.writewrites | Remove a strategy and every version of it |
get_strategy_schema | section?, withExamples? | strategy.read | The definition schema, one section at a time, so an author is not handed the whole document before writing a single entry condition |
diff_strategy_versions | id, from?, to? | strategy.read | What changed between two versions |
explain_strategy | id, version? | strategy.read | A saved strategy, described in prose |
explain_strategy_definition | definition | strategy.read | The same explanation for a draft that has never been saved |
Refinement is a list of operations, not a rewrite. Each one names a dotted path into the definition and what to do to it, which is what lets a model change a risk floor without restating a strategy it might get wrong. refine_strategy accepts these verbs:
curl -s -X POST http://127.0.0.1:8420/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'X-PS-Scopes: strategy.read,strategy.write' \ -d '{"jsonrpc":"2.0","id":9,"method":"tools/call", "params":{"name":"refine_strategy", "arguments":{"id":"breakout_1h", "ops":[{"op":"set","path":"risk.minRR","value":2}, {"op":"unset","path":"risk.maxDailyLoss"}], "changeSummary":"raise the reward floor"}}}'Backtest tools
7 tools, one of them market-typed
Backtests are jobs, not calls. Starting one answers immediately with a job id; you either poll it or subscribe to the backtest channel on the socket and be told.
| Tool | Inputs | Scope | What it does |
|---|---|---|---|
{spot|futures}_run_backtest | strategyId?, version?, definition?, symbol?, platform?, timeframe?, range?, fees?, slippageBps?, initialEquity?, quoteCurrency? | backtest.runwrites | Queue a backtest of a saved strategy or of an inline definition, over a bar range, with the fees, slippage and starting equity you choose. Answers with a job id and a queued status, never with a report |
get_backtest | jobId | backtest.read | The job, and its report once the job is done. A report that is no longer available comes back as a warning rather than as a job that vanished |
list_backtests | strategyId?, status?, limit? | backtest.read | Jobs, filterable by strategy and by status |
cancel_backtest | jobId | backtest.runwrites | Ask a job to stop. Idempotent: cancelling one that already finished is not an error, it simply reports that nothing was cancelled |
compare_backtests | jobIds | backtest.read | Two job ids, scored side by side |
score_trades | trades, equityCurve?, initialEquity?, quoteCurrency? | backtest.read | Run the same scoring the backtester uses over a list of trades you supply. Nothing is stored |
Job statuses. The last three are terminal
There is no delete tool. delete_backtest is not part of this surface: permanently removing a stored job and its report is a REST-only operation, because it is the one thing on this module an agent should not be able to do on its own. cancel_backtest stops a job and can be called as many times as you like, but it never removes one.
Only one code means the job is gone. BACKTEST_JOB_NOT_FOUND A job that failed, was cancelled or whose report is no longer available still answers. Treat anything else as a job that still exists.
curl -s -X POST http://127.0.0.1:8420/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'X-PS-Scopes: backtest.run,backtest.read' \ -d '{"jsonrpc":"2.0","id":10,"method":"tools/call", "params":{"name":"spot_run_backtest", "arguments":{"strategyId":"breakout_1h", "platform":"binance","symbol":"BTCUSDT","timeframe":"1h", "range":{"bars":2000}, "fees":{"takerPct":0.04},"slippageBps":1, "initialEquity":10000}}}'{ "jsonrpc": "2.0", "id": 10, "result": { "content": [ { "type": "text", "text": "…" } ], "structuredContent": { "jobId": "…", "status": "queued" } } }Journal tools
8 tools, all global
The journal records trades and what you were thinking when you took them. Entries the engine captured from your own order stream sit beside entries you wrote by hand, and both are scored the same way.
| Tool | Inputs | Scope | What it does |
|---|---|---|---|
list_journal_entries | from?, to?, symbol?, platform?, marketType?, status?, origin?, direction?, strategyId?, setup?, tag?, offset?, limit? | journal.read | Entries, filtered and paged. Every filter is optional and they compose |
get_journal_entry | id | journal.read | One entry |
create_journal_entry | entry | journal.writewrites | Write an entry by hand, for a trade placed somewhere this engine never saw |
update_journal_entry | id, patch | journal.writewrites | Patch the parts of an entry that are opinion rather than fact. An attachments patch replaces the list rather than adding to it |
delete_journal_entry | id | journal.writewrites | Remove an entry |
get_journal_stats | from?, to?, symbol?, …, initialEquity? | journal.read | Aggregate performance over any filtered slice, alongside a behaviour block covering how you traded rather than only what it returned |
get_journal_calendar | month, timezoneOffsetMinutes?, from?, to?, symbol?, … | journal.read | A dense month grid. A trade lands on the day it closed, in UTC unless you pass an offset |
derive_trade_outcomes | records | journal.read | Pair arbitrary order records into round trips and score them, without writing anything to the store. Useful for records this engine never held |
Do not write what was already captured. captured entries derive themselves from the engine’s own order stream and are idempotent; manual entries are for trades placed somewhere else. Writing an entry for a trade this engine watched gives you the same trade twice, and every statistic downstream believes both.
Identity cannot be patched. The symbol, the direction and the entry facts are what an entry IS: to change one of them, delete the entry and write it again. Everything else the notes, the tags, the grades, the mood, the reflection is patchable for as long as you keep the entry. Attachments carry a reference, never bytes.
curl -s -X POST http://127.0.0.1:8420/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'X-PS-Scopes: journal.read,journal.write' \ -d '{"jsonrpc":"2.0","id":12,"method":"tools/call", "params":{"name":"create_journal_entry","arguments":{"entry":{ "platform":"binance","marketType":"spot","symbol":"BTCUSDT", "direction":"long","status":"closed", "entryTime":"2026-09-08T09:15:00Z","entryPrice":61250, "exitTime":"2026-09-08T11:40:00Z","exitPrice":62180, "qty":0.15,"plannedStop":60800, "setup":"range break","tags":["london"],"mood":"calm"}}}}'Market-event tools
3 tools · live only
The engine watches the tape and the book for named patterns and scores each one it finds. Nothing here is simulated or backfilled: an event exists because it happened while the engine was watching.
| Tool | Inputs | Scope | What it does |
|---|---|---|---|
{spot|futures}_get_market_events | symbol?, platform?, since?, until?, limit?, types?, side?, urgency?, minScore? | events.read | Scored events for one pair or for a whole market type, filtered by type, side, urgency, score floor and time window |
list_market_event_types | None | events.read | The event vocabulary and the score thresholds that separate low, medium and high urgency |
16 event types
Every event carries a score. It runs from 0 to 100, and it is what urgency is derived from. Filter on urgency for a human-sized feed and on minScore when you want a hard floor. The thresholds are published by list_market_event_types, so a client never has to hard-code them.
Repeats are updates, not duplicates. An event that keeps happening at the same price is re-scored and re-emitted rather than appended, and it says how many times it has been seen and over what window. For a live feed rather than a query, the socket carries a market-events channel.
curl -s -X POST http://127.0.0.1:8420/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'X-PS-Scopes: events.read' \ -d '{"jsonrpc":"2.0","id":13,"method":"tools/call", "params":{"name":"spot_get_market_events", "arguments":{"platform":"binance","symbol":"BTCUSDT", "types":"absorption,liquidity_sweep,bos", "urgency":"high","minScore":75,"limit":50}}}'The seven subscription tools
An agent that can watch the market while it is not running
A subscription is one predicate evaluated against live market data plus an HTTP endpoint the engine POSTs to when it fires. This is how a model arranges to be told about something later.
create_subscription, list_subscriptions, get_subscription, update_subscription, delete_subscription, test_subscription and list_events are the same code path as the REST subscription routes, and they return the same records.
enabled is the only mutable field: changing a rule means delete and create. test_subscription proves an endpoint is reachable without waiting for a rule to fire, and it succeeds even when the delivery fails read the result rather than the absence of an error.
Creating, updating and deleting a subscription all sit on the subscriptions.write scope, and none of them is behind the confirmation gate: a rule that fires only sends an HTTP request to an endpoint you nominated yourself.
| Engine code | HTTP | Envelope code |
|---|---|---|
invalid_request | 400 | validation_error |
not_found | 404 | not_found |
limit_reached | 429 | too_many_requests |
forbidden_url | 403 | forbidden_url |
duplicate | 409 | duplicate |
disabled | 503 | service_unavailable |
Rule grammar
Five address fields, one operator, one value
Exactly one predicate per rule there is no AND, OR or NOT. The same string is accepted verbatim over REST.
<platform>:<marketType>:<symbol>:<timeframe>:<path> <operator> <value> binance : spot : BTCUSDT : 30m : <path> crosses_above 68000 | | | | | | |platform | symbol | path operator threshold marketType timeframe<path> addresses a value inside the pair’s published context. Call spot_get_compiled_context to see exactly which paths a pair currently exposes, then write the dotted route to the value you care about. Paths are case-sensitive, and the timeframe field is always required because it routes the rule into the evaluation index.
| Operator | Value | Semantics |
|---|---|---|
> < >= <= | number | True for as long as the level comparison holds |
== != | number or string | Strict and type-aware no coercion, so the string "46" never equals the number 46. Strings compare case-sensitively after trim |
crosses_above | number | Fires on the transition upward through the threshold, not while above it |
crosses_below | number | Fires on the transition downward through the threshold |
enters | string | Fires on the transition into that state |
exits | string | Fires on the transition out of that state |
A stored rule is not necessarily a sampling rule
The pair has to be tracked. Creating a subscription does not start tracking a pair that would change the engine’s workload without being asked. A rule addressed at a pair you have not favorited, or at a timeframe that favorite does not track, is accepted and stored and then stays inert. Favorite the pair first.
Level rules and edge rules behave differently. > is true for as long as the value stays above the threshold, so it fires immediately if the condition is already true when you create it. crosses_above needs a previous sample below and a current sample at or above, so it never fires on the first observation. Read them as “tell me while it is above” versus “tell me when it goes above”.
Confirmation, cooldown and hysteresis are always on. They have defaults and you may tune them per subscription, but there is no way to switch any of them off. One noisy tick cannot alert you, a permanently true condition cannot alert you repeatedly, and a value hovering on a threshold has to travel back through a re-arm band before the rule may fire again.
Four surfaces, one port
Coverage is deliberately uneven, and flattening it would be the easiest thing on this page to get wrong. Market context is on all four. Chart rendering is on three a terminal client has nowhere to put a PNG. Webhook subscriptions are on two, REST and MCP, by decision: a socket you have to keep open is not what a webhook is for.
http://127.0.0.1:8420/mcp
MCP stateless Streamable HTTP, 114 typed tools at 1-to-1 REST parity.
You are here
node server/cli.js
CLI a terminal client over REST and /stream. 11 commands, payload on stdout.
Ships with the engine