mcp-server-saxo
# mcp-server-saxo
TypeScript MCP server for the Saxo Bank OpenAPI. Works against both the **SIM**
(simulation/demo) and **LIVE** environments. Same shape as the rest of the
Borgels MCP server family: typed, documented, policy-aware, credential-sane,
and audit-friendly.
> **Disclaimer:** This is an independent, unofficial project by Borgels.
> Borgels is not affiliated with, endorsed by, or supported by Saxo Bank A/S.
> "Saxo", "Saxo Bank", and the Saxo OpenAPI are referenced only to describe
> what this server talks to. You need your own Saxo developer credentials, and
> use of the Saxo OpenAPI is subject to Saxo Bank's own terms and licensing.
> **Trading on LIVE moves real money. You are responsible for any orders this
> server places on your behalf.**
## Scope
Supported Saxo OpenAPI service groups:
- Root (session, diagnostics)
- Reference Data (instruments, exchanges)
- Trading (info prices, snapshot chart, order place / modify / cancel / precheck)
- Portfolio (accounts, balances, positions, closed positions, orders)
- Value Add (price alert definitions and notification settings)
Streaming subscriptions (WebSocket), Value Add performance, and Client
Management beyond `accounts/me` are out of scope for v1.
## Quickstart on SIM
```sh
npm install
npm run build
cp .env.example .env
```
Then pick one of:
### Path A — 24-hour token (quickest, one-shot)
1. Sign up for a free SIM account at <https://www.developer.saxo/>.
2. **App Management → Generate 24-hour token** (no app required).
3. Paste it into `.env`:
```
SAXO_ENVIRONMENT=sim
SAXO_ACCESS_TOKEN=...
```
4. Start the server (see Path A/B `start` block below).
The token expires after 24h; you'll need to repeat step 2 to keep going.
### Path B — OAuth app (refreshes automatically)
For anything you run longer than a day, do the OAuth dance once and let
the server refresh tokens for you. This is also the path you'll need for
LIVE.
1. Sign up at <https://www.developer.saxo/>.
2. **App Management → Create application** (mark as SIM, allow trading
if you'll be placing orders). Pick either grant type — the server
supports both:
- **Code** (confidential client, has an App Secret)
- **PKCE** (public client, no secret — slightly safer for
distributable clients but functionally equivalent for this server)
3. Register the redirect URL in the portal:
- **For Code apps**: register exactly `http://localhost:8765/callback`.
Saxo matches the full URL including port at runtime, and rejects
IP-literal redirects like `http://127.0.0.1:...`, so use the
hostname.
- **For PKCE apps**: register `http://localhost/callback` — Saxo's
PKCE flow requires the registered URL to **omit the port**
("When registering the redirect URL with your application, it
cannot include a port number"). The server still sends
`http://localhost:8765/callback` at runtime; Saxo matches
port-blind. Mismatching this returns `unauthorized_client`.
4. Put the credentials in `.env`:
```
SAXO_ENVIRONMENT=sim
SAXO_APP_KEY=...
SAXO_APP_SECRET=... # Only for Code-grant apps. Omit for PKCE.
SAXO_REDIRECT_URI=http://localhost:8765/callback
```
5. Run the auth CLI:
```sh
npm run auth -- --env sim
```
This opens your browser to Saxo's authorize page, you click Allow, and
the CLI writes `SAXO_ACCESS_TOKEN`, `SAXO_REFRESH_TOKEN`, and
`SAXO_TOKEN_EXPIRES_AT` back into `.env`. Both grant types use the
same flow; the server detects the absence of `SAXO_APP_SECRET` and
switches to PKCE-style token exchange (no Authorization header,
`client_id` in the form body).
The OAuth access token Saxo issues is short-lived (~20 minutes on SIM),
but `SaxoClient` proactively refreshes it from the refresh token ~60s
before expiry, so the server stays alive indefinitely.
### Start the server
```sh
npm run dev # stdio transport
# or
npm run dev:http # http://127.0.0.1:3000/mcp
```
### Market data is a second, separate consent
The 24-hour token unlocks **authenticated API access**, but live bid/ask
quotes via `/trade/v1/infoprices` require a **separate per-exchange market
data agreement** (NYSE, OPRA, EUREX, etc.). Until you accept it,
`saxo_get_infoprice` returns `PriceTypeAsk/Bid: "NoAccess"` with `Amount: 0`,
and `saxo_session_me` reports `MarketDataViaOpenApiTermsAccepted: false`.
There is **no Saxo OpenAPI endpoint** to flip this flag programmatically —
it's a human consent screen. Find it in the Saxo trading platform
(SaxoTraderGO → settings → live data subscriptions) or developer.saxo. Once
accepted, the same token starts returning quotes (typically `DelayedByMinutes: 15`
unless the current OpenAPI session has `TradeLevel: "FullTradingAndChat"`).
`DataLevel` should stay at Saxo's default `Standard`; Saxo documents that it
has no impact for third-party applications.
Use `saxo_get_session_capabilities` to read `TradeLevel` directly, and
`saxo_set_session_trade_level` to switch between `FullTradingAndChat` and
`OrdersOnly`. Only one session per Saxo user can have `FullTradingAndChat`, so
upgrading this MCP session can downgrade SaxoTraderGO/PRO or another OpenAPI
session. `saxo_diagnostics` watches session events and flags `TradeLevel`
problems in `warnings[]`; infoprices responses are decorated with `_warning`
when they're NoAccess.
## Authentication
This server reads credentials from environment variables only — they are
never accepted as tool arguments.
| Variable | Required for | Notes |
| --- | --- | --- |
| `SAXO_ENVIRONMENT` | always | `sim` (default) or `live` |
| `SAXO_ACCESS_TOKEN` | always | Bearer token. 24-hour token for SIM, OAuth token for LIVE. |
| `SAXO_REFRESH_TOKEN` | LIVE / long-running SIM | Together with app credentials enables 401-auto-refresh. |
| `SAXO_APP_KEY` | refresh / OAuth | Application key from the developer portal. |
| `SAXO_APP_SECRET` | refresh / OAuth | Application secret. |
| `SAXO_REDIRECT_URI` | OAuth | Defaults to `http://localhost:8765/callback`. Loopback only — and Saxo's authorize endpoint rejects IP-literal redirects, so use the `localhost` hostname rather than `127.0.0.1`. The URL in your app's Redirect URLs list must match exactly. |
| `SAXO_TIMEOUT_MS` | optional | Request timeout in ms (default 30000). |
### Two ways to log in for LIVE / long-running SIM
**Option A — CLI (one-off, scriptable):**
```sh
SAXO_APP_KEY=... SAXO_APP_SECRET=... npm run auth -- --env live
```
The CLI starts a local callback listener, opens your browser to the Saxo
authorize endpoint with a PKCE challenge, and writes
`SAXO_ACCESS_TOKEN`/`SAXO_REFRESH_TOKEN`/`SAXO_TOKEN_EXPIRES_AT` back into
`.env`.
**Option B — From inside the MCP client (`saxo_oauth_*` tools):**
1. Set `SAXO_APP_KEY` + `SAXO_APP_SECRET` in the MCP server environment. In
packaged clients such as Claude Desktop / MCPB this usually means client
config, not a local env file.
2. For the smooth local flow, call `saxo_oauth_login`. It starts the loopback
listener, opens the browser by default, waits for approval, exchanges the
code, and updates the running MCP server in memory.
3. Tokens are not written to disk unless `writeToEnvFile=true` is supplied.
Use `envFilePath` when you want a specific file such as `.env.local`.
For clients that want to control their own UI, use the lower-level two-step
flow: call `saxo_oauth_start`, open the returned `authorizeUrl` (or set
`openBrowser=true`), then call `saxo_oauth_complete` with the returned
`ticketId`.
The MCP server only listens on loopback (`127.0.0.1`) for the callback, so the
flow never touches the public network beyond Saxo itself.
## Install
The server is published as **`@borgels/mcp-server-saxo`** on npm and
as an **`.mcpb` bundle** (MCP Bundle, the new name for DXT) on each
[GitHub Release](https://github.com/Borgels/mcp-server-saxo/releases).
The protocol layer is the same everywhere — stdio + MCP JSON-RPC. The
table below is just the per-client config syntax.
| Client | How to install |
| --- | --- |
| **Claude Desktop (1.8089+)** | Download `mcp-server-saxo-v<version>.mcpb` from the latest [Release](https://github.com/Borgels/mcp-server-saxo/releases), then **Settings → Connectors → Install from file**. Claude Desktop prompts you for the SAXO_* config values from the bundle's user_config schema. |
| **Claude Desktop (legacy `claude_desktop_config.json`)** | Add the [universal JSON block](#universal-config) to `%APPDATA%\Claude\claude_desktop_config.json` under `mcpServers`. Restart fully (File → Exit). |
| **Claude Code (CLI)** | `claude mcp add saxo -- npx -y @borgels/mcp-server-saxo` (or edit `~/.claude/mcp.json` / per-project `.mcp.json`). |
| **Cursor** | Settings → MCP → Add server, or `.cursor/mcp.json` per project. Same JSON shape as below. |
| **Codex (OpenAI)** | `~/.codex/config.toml`: `[mcp_servers.saxo]` with `command = "npx"`, `args = ["-y", "@borgels/mcp-server-saxo"]`, `env = { SAXO_ENVIRONMENT = "sim", ... }`. |
| **Windsurf / Cline / Zed / continue.dev** | Settings UI, point at `npx -y @borgels/mcp-server-saxo`. Same JSON envelope. |
| **MCP Inspector (debug)** | `npx @modelcontextprotocol/inspector npx -y @borgels/mcp-server-saxo` |
| **Self-host (any client)** | `command: "node", args: ["/absolute/path/to/dist/transports/stdio.js"]`. Use double backslashes on Windows or forward slashes. |
### Universal config
For any client that uses the standard `mcpServers` config schema:
```json
{
"mcpServers": {
"saxo": {
"command": "npx",
"args": ["-y", "@borgels/mcp-server-saxo"],
"env": {
"SAXO_ENVIRONMENT": "sim",
"SAXO_ACCESS_TOKEN": "your-24h-sim-token"
}
}
}
}
```
That's the **minimal** form — fine for a quick SIM test. For durable
use (no daily token paste), do the OAuth dance once with
`npm run auth -- --env sim` against a clone of this repo, then copy
the resulting OAuth env vars into the block:
```json
{
"mcpServers": {
"saxo": {
"command": "npx",
"args": ["-y", "@borgels/mcp-server-saxo"],
"env": {
"SAXO_ENVIRONMENT": "sim",
"SAXO_APP_KEY": "...",
"SAXO_APP_SECRET": "...",
"SAXO_REFRESH_TOKEN": "...",
"SAXO_ACCESS_TOKEN": "...",
"SAXO_TOKEN_EXPIRES_AT": "2026-05-19T18:00:37.823Z",
"SAXO_ENABLE_LIVE_TRADING": "false"
}
}
}
}
```
`SAXO_ACCESS_TOKEN` may be expired at startup — `SaxoClient` decodes
the JWT `exp`, detects it's within 60s of expiry, and runs the
refresh-token grant before sending the first request. The refresh
token is what really matters at cold start.
### Restart after editing
Most MCP clients spawn server processes only at startup. After editing
the client's config, fully quit and reopen it. Some clients (notably
Claude Desktop) keep running in the system tray when the window is
closed — make sure you actually exit before reopening.
### Troubleshooting
- If the server doesn't appear in the client's tool list, check the
client's MCP logs (each client documents its log location).
- Verify `npx -y @borgels/mcp-server-saxo` runs from a fresh shell — it
should start, register tools, and wait for stdin without error.
- Once connected, call `saxo_diagnostics` first when something looks
off — its `warnings[]` array surfaces missing market-data terms,
near-expiry tokens, `TradeLevel` not FullTradingAndChat, and LIVE order writes without
`SAXO_ENABLE_LIVE_TRADING`.
### Building from source (for hacking or before npm publish)
```sh
git clone https://github.com/Borgels/mcp-server-saxo.git
cd mcp-server-saxo
npm install
npm run build # produces dist/transports/stdio.js
npm test
```
Use `"command": "node", "args": ["/absolute/path/to/dist/transports/stdio.js"]`
in your client config to point at the built source. For the MCPB bundle,
`npm run mcpb:pack` produces `mcp-server-saxo.mcpb` in the project root.
## Start Here
Use `saxo_capabilities` first when an MCP client needs to decide which Saxo
tool to call. It returns tool descriptions, examples, identifier formats, and
safety notes without contacting Saxo.
```json
{ "query": "place order", "limit": 5 }
```
### Optional Alpha Vantage enrichment
The strategy screeners are Saxo-first. They work without third-party data using
Saxo instruments, prices, chart bars, option chains, and account/position
context. If `ALPHA_VANTAGE_API_KEY` is set, stock and option strategy tools can
optionally enrich candidates with Alpha Vantage `OVERVIEW`,
`NEWS_SENTIMENT`, and `EARNINGS_CALENDAR`.
Leave `ALPHA_VANTAGE_API_KEY` unset to keep the server Saxo-only. Alpha
Vantage is optional because tiers and rate limits vary. For deeper research,
run Alpha Vantage's own MCP server beside this Saxo server and pass normalized
research into `externalContextBySymbol`.
### Factor and portfolio context
Use `saxo_screen_stock_factors` when you want stock factor context with Saxo
quote/liquidity data, Saxo chart context, optional account sizing, optional
fundamentals/news enrichment, and warnings:
```json
{
"accountKey": "your-account-key",
"market": "us",
"universe": "large_cap",
"objective": "balanced",
"riskProfile": "balanced",
"includeAccountContext": true,
"includeFundamentalContext": true,
"riskBudgetPercentPerIdea": 1,
"maxSingleNamePercent": 10,
"maxResults": 10
}
```
Use `saxo_screen_option_strategy_factors` when you want optionable underlyings
and explicit strategy candidates with liquidity, structure, chart, IV/Greeks,
optional news, and account sizing factors:
```json
{
"accountKey": "your-account-key",
"market": "us_nasdaq",
"underlyingUniverse": "auto",
"strategies": ["put_credit_spread", "iron_condor"],
"includeAccountContext": true,
"riskBudgetPercent": 1,
"requireGreeks": true,
"maxThetaDailyPercentOfRisk": 1,
"maxUnderlyings": 50,
"maxUnderlyingScan": 500,
"maxSymbolsToPlan": 5,
"maxPlans": 10
}
```
Option candidate generation uses Saxo option-chain quotes, Saxo Greeks,
optional OptionsChain IV context, and optional account-aware sizing. The output
contains factor scores and warnings, not pass/watchlist/reject verdicts or
confidence labels.
Use `saxo_analyze_portfolio_context` for whole-account context. It reads the
account snapshot, runs the stock and option factor tools, then returns risk
budgets, stock/option factor summaries, concentration context, sector context,
and warnings. It does not return target allocations, deployment stages, or
recommended contract counts:
```json
{
"accountKey": "your-account-key",
"objective": "balanced_growth_income",
"riskProfile": "balanced",
"portfolioProfile": "concentrated_conviction",
"deploymentStyle": "staged",
"targetInvestedPercent": 80,
"cashReservePercent": 10,
"maxCashDollars": 1000,
"maxSingleNamePercent": 10,
"maxSectorPercent": 35,
"maxOptionsRiskPercent": 5,
"riskBudgetPercentPerIdea": 1,
"maxSelectedUnderlyings": 5,
"minPositionRiskDollars": 2500,
"maxContractsPerPosition": 20,
"fragmentationPolicy": "reject",
"requireGreeks": true,
"maxThetaDailyPercentOfRisk": 1,
"stockUniverse": "large_cap",
"stockMaxCandidates": 120,
"discoverOptionCandidates": true,
"optionDiscoveryUniverse": "auto",
"optionTheses": [
{
"name": "Long-term stock replacement",
"symbols": ["NVO"],
"role": "core_conviction",
"conviction": "high",
"horizon": "leaps",
"preferredStructures": ["long_call", "debit_spread"],
"targetRiskPercent": 5
}
],
"includeStocks": true,
"includeOptions": true
}
```
Set `discoverOptionCandidates=true` to blend user-supplied option symbols or
theses with deterministic discovery from Saxo market movers. If the Saxo
account is not approved for short option legs, set `allowShortOptionLegs=false`;
short-leg structures are filtered from candidate generation.
Use `saxo_review_strategy_positions` after execution to monitor open stock and
option strategies against the plan you opened. Pass the executed stock leg or
option legs plus entry metrics from the selected plan or fill. The tool matches
those legs to open Saxo positions, refreshes quotes, estimates current value
and P/L, and evaluates deterministic profit-taking/loss rules. Option
strategies additionally include Greeks, theta, DTE, roll, and close rules. It
returns verdicts such as `hold`, `review`, `consider_trim`, `consider_close`,
and `roll_watch`; execution remains a separate explicit order workflow.
## Tools
All tools are registered with MCP annotations (`readOnlyHint`,
`destructiveHint`, `idempotentHint`, `openWorldHint`) so clients can reason
about safety. Read-only tools work on SIM and LIVE without extra opt-in. Write
tools (orders, OAuth) follow the [LIVE Trading Safety](#live-trading-safety)
rules.
### Read-only
| Tool | Endpoint | Purpose |
| --- | --- | --- |
| `saxo_capabilities` | — | Discover tools without calling Saxo. |
| `saxo_session_me` | `GET /port/v1/users/me` | Authenticated user (Name, ClientKey, UserKey, MarketDataViaOpenApiTermsAccepted). |
| `saxo_get_session_capabilities` | `GET /root/v1/sessions/capabilities` | Current AuthenticationLevel, DataLevel, and TradeLevel without running diagnostics. |
| `saxo_diagnostics` | (aggregated) | Session + capabilities + token expiry + warnings (market-data terms, TradeLevel, token close to expiry). |
| `saxo_feature_availability` | `GET /root/v1/features/availability` | Inspect Saxo feature flags for News, Calendar, Gainers/Losers, and Chart. |
| `saxo_search_instruments` | `GET /ref/v1/instruments` | Search by keyword + asset type. |
| `saxo_get_instrument_details` | `GET /ref/v1/instruments/details` | Detailed metadata for one or many Uics. |
| `saxo_list_exchanges` | `GET /ref/v1/exchanges` | List exchanges (or one by ExchangeId). |
| `saxo_get_option_chain` | `GET /ref/v1/instruments/contractoptionspaces/{optionRootId}` | Strikes + expirations. `normalize=true` (default) pivots Put/Call into one row per strike. |
| `saxo_list_option_expiries` | (uses option chain) | Cheap helper: just the expiries (date, days, strike count) for an option root. |
| `saxo_list_standard_option_expiries` | `GET /ref/v1/standarddates/optionexpiry` | Standardized option-expiry calendar (3rd Friday monthlies, quarterlies, weeklies). Distinct from `list_option_expiries`. |
| `saxo_find_option_leg` | (composes search + chain) | Convenience helper: given symbol + expiry + strike + Call/Put, returns the leg Uic in one call instead of 4. Picks multi-leg-capable root when ambiguous. |
| `saxo_get_infoprice` | `GET /trade/v1/infoprices` | Snapshot bid/ask/last for one instrument. Adds `_warning` if `PriceType=NoAccess`. |
| `saxo_get_infoprices_list` | `GET /trade/v1/infoprices/list` | Snapshot prices for multiple Uics. |
| `saxo_get_chart` | `GET /chart/v3/charts` | Historical OHLC bars (horizon in minutes). |
| `saxo_screen_market` | Saxo instruments + info prices | User-friendly top gainers/losers and pre-market screeners. |
| `saxo_compute_spread_quote` | (uses infoprices) | Fetch bid/ask per leg and compute worst-case, mid, best-case net debit for a multi-leg spread. |
| `saxo_estimate_vertical_spread` | (pure math) | Given side + strikes + debit + contracts: max loss, max gain, breakeven, R/R. Applies 100x option multiplier. |
| `saxo_generate_option_strategy_candidates` | Option chain + prices | Explicit option strategy candidates with factor scores. |
| `saxo_screen_option_strategy_factors` | Market screener + chart TA + OptionsChain IV + planner | Cross-symbol option factor screening. |
| `saxo_screen_stock_factors` | Saxo instruments + prices + chart TA + optional fundamentals/news | Stock factor screening without verdicts. |
| `saxo_analyze_portfolio_context` | Account snapshot + stock/options factor tools | Whole-account factor, budget, and constraint context. |
| `saxo_review_strategy_positions` | Positions + quotes (+ Greeks for options) | Post-execution strategy follow-up with hold/trim/close/roll verdicts. |
| `saxo_list_accounts` | `GET /port/v1/accounts/me` | List the client's trading accounts. |
| `saxo_get_balance` | `GET /port/v1/balances` | Cash + margin balance. |
| `saxo_list_positions` | `GET /port/v1/positions/me` | Open positions (one row per fill). |
| `saxo_list_net_positions` | `GET /port/v1/netpositions/me` | Positions aggregated per instrument (one row per Uic). Right view for current exposure. |
| `saxo_list_closed_positions` | `GET /port/v1/closedpositions/me` | Closed positions / history. |
| `saxo_list_activities` | `GET /port/v1/activities` | Recent account events: orders placed/modified/cancelled, trades, dividends, corporate actions. |
| `saxo_list_orders` | `GET /port/v1/orders/me` | Working orders. |
| `saxo_get_order` | `GET /port/v1/orders/{orderId}` | One order by id. |
| `saxo_list_price_alerts` | `GET /vas/v1/pricealerts/definitions` | List Saxo price alerts, optionally by state. |
| `saxo_get_price_alert` | `GET /vas/v1/pricealerts/definitions/{id}` | One price alert definition by id. |
| `saxo_get_price_alert_user_settings` | `GET /vas/v1/pricealerts/usersettings` | Price-alert email/popup/sound settings. |
### Write — guarded
| Tool | Endpoint | Guards |
| --- | --- | --- |
| `saxo_precheck_order` | `POST /trade/v2/orders/precheck` | Policy + audit. No execution. |
| `saxo_place_order` | `POST /trade/v2/orders` | LIVE: `SAXO_ENABLE_LIVE_TRADING=true` + `policy.json` allow + optional auto-precheck. |
| `saxo_modify_order` | `PATCH /trade/v2/orders` | Same as place_order. |
| `saxo_cancel_order` | `DELETE /trade/v2/orders/{ids}` | Policy + audit. |
| `saxo_precheck_multileg_order` | `POST /trade/v2/orders/multileg/precheck` | Validate a spread (no execution). |
| `saxo_place_multileg_order` | `POST /trade/v2/orders/multileg` | Place a spread atomically with a single net debit/credit limit. |
| `saxo_modify_multileg_order` | `PATCH /trade/v2/orders/multileg` | Adjust spread Amount or OrderPrice. |
| `saxo_cancel_multileg_order` | `DELETE /trade/v2/orders/multileg/{id}` | Cancel the whole strategy. |
| `saxo_create_price_alert` | `POST /vas/v1/pricealerts/definitions` | LIVE alert writes require `SAXO_ENABLE_LIVE_ALERT_WRITES=true` + policy allow. |
| `saxo_update_price_alert` | `PUT /vas/v1/pricealerts/definitions/{id}` | Partial tool input is merged with the current definition before PUT. |
| `saxo_delete_price_alerts` | `DELETE /vas/v1/pricealerts/definitions/{ids}` | Delete one or more price alerts. |
| `saxo_update_price_alert_user_settings` | `PUT /vas/v1/pricealerts/usersettings` | Update email/popup/sound settings. |
| `saxo_set_session_trade_level` | `PATCH /root/v1/sessions/capabilities` | LIVE requires `policy.allow_live_session_capability_writes=true`; can downgrade other Saxo sessions. |
| `saxo_oauth_login` | OAuth2 PKCE | One-call local login; updates in-process tokens. Optional env-file persist. |
| `saxo_oauth_start` | OAuth2 PKCE | Loopback redirect only; reads app creds from env. |
| `saxo_oauth_complete` | OAuth2 PKCE | Replaces in-process tokens. Optional `.env` persist. |
| `saxo_oauth_cancel` | — | Closes a pending OAuth listener. |
### Place / modify order body
Order tools accept Saxo's standard `POST /trade/v2/orders` body. Required
fields: `AccountKey`, `Uic`, `AssetType`, `BuySell`, `Amount`, `OrderType`,
`OrderDuration`. Optional: `OrderPrice` (Limit/StopLimit), `StopPrice`
(Stop/StopLimit), `ManualOrder`, `ExternalReference`, and `Orders[]` for
related orders (OCO, IfDone, brackets).
```json
{
"AccountKey": "your-account-key",
"Uic": 211,
"AssetType": "Stock",
"BuySell": "Buy",
"Amount": 1,
"OrderType": "Market",
"OrderDuration": { "DurationType": "DayOrder" }
}
```
### Multi-leg option order body
Multi-leg tools wrap Saxo's `/trade/v2/orders/multileg` family. `OrderType`
must be `Limit`. `OrderPrice` is always positive — the absolute limit
price you are willing to pay (debit spread) or receive (credit spread).
Saxo's API rejects negative `OrderPrice` with "Price cannot be
negative"; the debit/credit direction is implicit in each leg's
`BuySell`. `Legs[]` accepts 2–20 legs that all share the same option
root (same underlying + expiry).
```json
{
"AccountKey": "your-account-key",
"OrderType": "Limit",
"OrderPrice": 1.08,
"OrderDuration": { "DurationType": "GoodTillCancel" },
"ManualOrder": true,
"ExternalReference": "bull-call-spread-1",
"Legs": [
{
"Uic": 14853018,
"AssetType": "StockOption",
"BuySell": "Buy",
"Amount": 150,
"ToOpenClose": "ToOpen"
},
{
"Uic": 14853056,
"AssetType": "StockOption",
"BuySell": "Sell",
"Amount": 150,
"ToOpenClose": "ToOpen"
}
]
}
```
Saxo returns a `MultiLegOrderId` plus per-leg `Orders[].OrderId` values.
Use `saxo_modify_multileg_order` (Amount/OrderPrice only) or
`saxo_cancel_multileg_order` (cancels the whole strategy) afterwards. To
find the per-leg Uics, start with `saxo_search_instruments` for the
underlying, then `saxo_get_option_chain` to read off strikes and
expirations.
## LIVE Trading Safety
LIVE writes are denied by default. To enable them you must do **all three**:
1. Set `SAXO_ENVIRONMENT=live`.
2. Set `SAXO_ENABLE_LIVE_TRADING=true`.
3. Point `SAXO_POLICY_PATH` at a `policy.json` that sets
`"allow_live_writes": true`.
Price-alert writes are separate from trading writes. To create, update, delete,
or change notification settings on LIVE you must set
`SAXO_ENABLE_LIVE_ALERT_WRITES=true` and set
`"allow_live_alert_writes": true` in policy. This does not enable order
placement.
A copy of `policy.example.json` is included. Supported fields:
| Field | Effect |
| --- | --- |
| `allow_live_writes` | Master switch for all order writes on LIVE. |
| `allow_live_alert_writes` | Master switch for price-alert create/update/delete/settings writes on LIVE. |
| `allow_live_session_capability_writes` | Allows changing LIVE session TradeLevel (`FullTradingAndChat` / `OrdersOnly`). |
| `require_precheck_on_live` | Place-order automatically runs precheck first. |
| `allow_short_option_legs` | Set to `false` when the Saxo option profile does not permit opening short option legs; multi-leg write tools then block sell-to-open option legs before calling Saxo. |
| `allowed_asset_types` | Whitelist of AssetTypes that may be ordered. |
| `allowed_account_keys` | Whitelist of AccountKeys that may be traded. |
| `denied_uics` | Blocklist of Uics. |
| `max_order_amount` | Per-AssetType caps; `default` falls back when no specific entry. |
| `max_notional` | Cap on `Amount * (OrderPrice or StopPrice) * contract_multiplier`. Multiplier is 100 for `StockOption` / `IndexOption` / `StockIndexOption` / `FuturesOption`, 1 otherwise. For multi-leg spreads, applied as `|OrderPrice| * largest leg Amount * multiplier`. |
Even on SIM, all write tools run through the policy check (it just defaults
to permissive). Use the policy in SIM too if you want predictable limits.
## Optional HTTP Server
The local stdio transport is the default for agent compatibility. A small
Streamable HTTP entry point is also available:
```sh
PORT=3000 SAXO_ACCESS_TOKEN=... npm run dev:http
```
By default the HTTP server binds to `127.0.0.1`, limits request bodies to
10 MiB, allows browser CORS only from loopback origins, and does not require
an HTTP Bearer token. Override with `MCP_HTTP_HOST`, `MCP_MAX_BODY_BYTES`,
`MCP_ALLOWED_ORIGINS`, `MCP_ALLOW_ANY_ORIGIN=true`, and `MCP_HTTP_TOKEN`. The
MCP endpoint is `POST http://127.0.0.1:3000/mcp`.
## Verification
```sh
npm run typecheck
npm test
npm run build
```
Optional live SIM smoke test (requires a 24-hour SIM token):
```sh
SAXO_ACCESS_TOKEN="your-sim-token" npm run smoke:live
```
## Releasing
Tagged releases trigger
[`.github/workflows/release.yml`](.github/workflows/release.yml). On
`git push --tags vX.Y.Z`:
1. Typecheck, test, build, validate the MCPB manifest.
2. Publish `@borgels/mcp-server-saxo@X.Y.Z` to npm with `--provenance`
so the package carries a SLSA attestation linking it to the exact
CI run + commit.
3. Reinstall with `--omit=dev` and pack a small `.mcpb` bundle
(~2.6 MB; full dev install yields ~17 MB).
4. Create a GitHub Release with the matching CHANGELOG section as the
body and the `.mcpb` attached.
Pre-release tags (e.g. `v0.1.1-rc.1`) publish under the `next` npm
dist-tag and as a GitHub pre-release.
### One-time setup before the first release
1. **Create the npm org.** At
<https://www.npmjs.com/org/create>, create the `borgels`
organization (free, public packages).
2. **Bootstrap the first publish.** Trusted Publishing (see below)
can't be configured until the package exists on npm, so the very
first publish needs a token. Either:
- **Local one-shot:** `npm publish --access public` with a one-time
Automation Token logged in as a member of the `borgels` org. Run
this once from a clean checkout of the tagged commit. Revoke the
token immediately afterwards.
- **CI bootstrap:** add the token as the `NPM_TOKEN` repo secret,
push `v0.1.1-rc.1`, let CI publish, then delete the secret.
3. **Configure Trusted Publishing** (one-time, per package):
<https://www.npmjs.com/package/@borgels/mcp-server-saxo/access> →
*Trusted publishing* → Add publisher. Enter
- Organization: `Borgels`
- Repository: `mcp-server-saxo`
- Workflow filename: `release.yml`
- Environment: leave blank (or set to e.g. `production` if you
want to gate releases behind a GitHub Environment approval).
4. **Revoke the bootstrap token** and delete the `NPM_TOKEN` repo
secret. From this point on, every release authenticates via
GitHub Actions OIDC — no long-lived secrets anywhere.
The same three steps repeat once per sibling Borgels MCP server when
they migrate to this template.
## Rate Limits
Saxo applies per-service-group rate limits (typically ~120 requests per
minute per session per service group, and ~1 order per second). On `429` the
server preserves the `retry-after` header on the thrown `SaxoHttpError` so
callers can back off.
## Security And Audit
- All credentials (`SAXO_ACCESS_TOKEN`, `SAXO_REFRESH_TOKEN`, `SAXO_APP_KEY`,
`SAXO_APP_SECRET`) are read only from the MCP server environment.
- Credentials are never accepted as tool arguments.
- Error formatting and audit records redact `Authorization: Bearer ...`,
`access_token`, `refresh_token`, and `SAXO_APP_SECRET` style material.
- The OAuth listener only binds to loopback. Non-loopback `SAXO_REDIRECT_URI`
is rejected at startup.
- If `SAXO_AUDIT_LOG` is set, every tool call writes a JSONL line with
timestamp, request id, tool name, environment, action (`start` / `finish`
/ `error` / `policy_denied`), SHA-256 hash of the input, status, and
redacted error text. Raw inputs and tokens are not written.
- Reports of suspected vulnerabilities go privately to
<security@borgels.com>. Do not include credentials or personal data in
public GitHub issues.
## API Sources
- Saxo Developer Portal: <https://www.developer.saxo/>
- Saxo OpenAPI Reference: <https://www.developer.saxo/openapi/referencedocs>
- Saxo OpenAPI Learn: <https://www.developer.saxo/openapi/learn>
## License
Apache-2.0. See [LICENSE](LICENSE).
TDQS
Scored across 51 tools
Each tool targets a distinct function or resource type. Tools like list_positions vs list_net_positions, place_order vs place_multileg_order, and the various option-related tools are clearly differentiated by their descriptions, leaving no ambiguity.
All tools follow a consistent 'saxo_verb_noun' pattern in lowercase snake_case. Verbs are chosen appropriately and nouns specify the target, making the naming predictable and easy to navigate.
With 51 tools, the count exceeds 25, which per the rubric is considered too many for the apparent scope. While the server covers a complex trading domain, the tool surface feels bloated and could benefit from consolidation.
The tool set covers the full lifecycle of trading interactions: account management, order placement (single and multi-leg), prechecks, positions, market screening, alerts, OAuth, session management, and diagnostics. No obvious gaps are apparent for the stated purpose.