Imported from mvperez/alpacapal-archive (
apps/mcp/AGENTS.md). Install upstream withnpx skills add mvperez/alpacapal-archive --skill mcp. Copyright stays with the author.
AGENTS.md — Alpaca MCP Server (Cloudflare Worker)
A Cloudflare Python Worker (compatibility_flags: ["python_workers"], same pattern as
apps/backend-worker) exposing a minimal MCP (Model Context Protocol) server — account/market-data reads
plus order placement — over the Streamable HTTP transport, backed directly by Alpaca's REST API
via pyodide.http.pyfetch.
Why this exists, and why it's hand-rolled
Alpaca does not host a remote MCP server (confirmed on alpaca.markets/mcp-server and the
alpacahq/alpaca-mcp-server GitHub README) — their official server is stdio-first, Python
3.10+/uv, meant to run as a local subprocess. It also depends on the mcp Python SDK
(asyncio/Starlette/ASGI), which does not run in Cloudflare's Pyodide-based Python Workers, and
exposes ~100 tools AlpacaPal doesn't need.
This app implements only the JSON-RPC methods a real MCP client calls
(initialize, notifications/initialized, ping, tools/list, tools/call) against a small,
fixed set of tools, each a thin wrapper around an Alpaca REST call — same pyfetch-based request
pattern as apps/backend-worker/entry.py's get_alpaca/get_alpaca_data.
Deliberate simplifications (not full MCP spec compliance)
- JSON response mode only, no SSE. Every POST returns a single JSON body (or a JSON array for a batched request). The Streamable HTTP spec allows this as an alternative to an SSE stream; this server never upgrades to SSE.
- Sessions are not tracked server-side.
initializereturns a freshMcp-Session-Idheader (auuid4), but subsequent requests are not validated against it — Workers isolates aren't guaranteed to persist in-memory state between invocations, and this server is single-tenant behind a shared secret, so there is no real per-session state to protect. This is intentional and only safe because of the auth model below — don't build on this file assuming a stricter MCP server would behave the same way. GET/DELETEare stubs.GETreturns 405 (no server-initiated stream offered);DELETEreturns 200 with no server-side effect.
Auth
Every POST must carry the shared secret in one of Authorization (with or without a Bearer
prefix), api-key, or x-api-key — checked in is_authorized(), which accepts any of these
three header names so it doesn't depend on matching whatever header-name convention a given MCP
client UI defaults to. This is not optional: this endpoint reaches a real Alpaca account, and the
Streamable HTTP URL itself is not a secret. Set MCP_SHARED_KEY as a Worker secret, and use
that same value as the apiKey when registering this server via Anter's add_mcp_server
(authType: "api_key").
Alpaca credentials come from request headers only — no environment fallback. Every request
must carry an API key and secret. Canonical names are ALPACA_API_KEY and
ALPACA_SECRET_KEY; also accepted: ALPACA-API-KEY / ALPACA-SECRET-KEY, and
ALPACA_API_SECRET / ALPACA-API-SECRET. Missing keys make tools/call fail. The error
lists which headers arrived so a misnamed Anter custom header is obvious.
Trading mode is a per-call tool argument, not a connection header. Every tool declares
mode: {enum: ["paper", "live"]}. Omitted, empty, or missing mode is paper. Only an
explicit mode="live" on that tools/call hits https://api.alpaca.markets. A MODE request
header cannot promote the call to live — it is recorded for traces only. Invalid values are
rejected (mode must be 'paper' or 'live'). When registering via Anter's add_mcp_server /
update_mcp_server, supply the two Alpaca key headers as additional custom headers alongside
the MCP_SHARED_KEY auth header; do not rely on a MODE header to select live. Paper and
live Anter MCP registrations each need their own key pair on those headers.
Tools
Thin wrappers over Alpaca REST calls (not shared code with apps/backend/alpaca_client.py — this
Worker can't import that module, it re-implements what it needs with pyfetch):
- Account/portfolio (read-only):
get_account,get_positions(optionalsymbolsfilter),get_clock,get_portfolio_history(date_start/date_endrequired). - Market data (read-only):
get_snapshots(symbolsrequired — latest trade/quote/daily bar),get_bars(historical OHLCV for one symbol,symbol/start/endrequired),search_assets(queryrequired — substring match over active US-equity symbol/name, no Alpaca endpoint does server-side name search so this fetches the active-asset list and filters client-side),get_news(symbolsrequired). - Trading:
place_order— mirrorsalpaca_client.place_order's validation (market/limit/ stop/stop_limit, notional-vs-qty sizing, notional restricted to market orders). Executes immediately (no confirmation at this layer). Defaults to the paper host; passmode="live"to trade the live account.- Idempotency (
client_order_id): Callers/agents can supply an explicitclient_order_id(1-128 chars, alphanumeric +-_.:). Alpaca enforces idempotency against this key. If omitted by the client, the server generates a freshuuid4per call — so automated client retries on network dropouts MUST supply a consistentclient_order_idacross retries to prevent duplicate trades. - Hard per-order dollar cap, enforced server-side regardless of what the calling agent
believes:
MAX_ORDER_NOTIONAL_USD(Worker var, default1000). Notional orders reject outright above the cap. Qty-based orders have no stated dollar amount, solatest_price()looks up a live price and rejects if the estimated value (qty * price) exceeds the cap — and rejects (fails closed) if no price is available at all, rather than letting an unbounded qty order through. Finite and positive numbers are strictly enforced. - Approval gate lives in the AgentSpec, not here. This server has no concept of "ask for
permission" — that's enforced by the Anter runtime via the
mcp_toolset's per-toolconfigsoverride ({name: "place_order", permission: "ask"}") on whichever sub-agent is given this toolset, alongside adefault_config.permission: "allow"so the read-only/market -data tools aren't gated too (Anter labels all custom MCP tools "destructive" by default, with no per-tool nuance, so without this override every tool from this server would require approval). Seeget_runtime_semantics_guidetopicpermissions_and_approvalsfor why anask-gated tool is auto-denied (not silently allowed) on an unattended/scheduled run — if this agent is ever run non-interactively,place_orderwill always be denied there.
- Idempotency (
Security Architecture & Threat Model
- Constant-Time Secret Comparison: Auth tokens are verified using
hmac.compare_digestto prevent timing side-channel attacks. - In-Isolate Rate Limiting: Rate limiting (both request throughput and failed-auth lockout)
operates in-memory per Cloudflare isolate. Distributed brute-force attempts across edge isolates
are stopped by high-entropy 256-bit
MCP_SHARED_KEYtokens and constant-time compare. - NAT-Safe Auth: Valid authentication requests are never blocked by failed attempts from other clients behind the same NATed IP address.
- Fail-Closed Verification: All HTTP methods (GET, DELETE, POST) require valid auth before any routing occurs. CORS preflight (OPTIONS) is handled statelessly.
- URL & Input Sanitization: All symbols and URL parameters are sanitized and encoded with
urllib.parse.urlencode/quote, and validated against regex whitelists.
Config
wrangler.json:vars.MAX_ORDER_NOTIONAL_USD(default1000),vars.CORS_ORIGIN(default*). No Alpaca credentials or base-URL vars — the trade host is selected by the tool-callmodeargument (default paper).MCP_SHARED_KEYis the only Worker secret; set it withwrangler secret put MCP_SHARED_KEYbefore deploying. Local template:.env.example(copy to.dev.varsforwrangler dev).- Deploy:
wrangler deployfrom this directory. Nopackage.json/D1 binding needed.
Scope
Independent of apps/backend and apps/backend-worker — no shared code, no shared database. Treat as
out of scope for changes to those apps unless explicitly asked to touch the MCP surface.