@stratabook/mcp 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
3
3
  All notable changes to the Strata SDKs (`@stratabook/sdk`, `@stratabook/mcp`,
4
4
  and the `strata-sdk` Rust crate) are recorded here. Versions move together.
5
5
 
6
+ ## 0.2.18
7
+
8
+ - Hot-reload locally paired session credentials inside a running MCP process;
9
+ `connect`, session replacement, and disconnect no longer require a client
10
+ restart.
11
+ - Expose `strata_order_execute` in simple mode with human limit-order intent:
12
+ market label, side, percentage of available balance, and signed mark offset.
13
+ Strata resolves exact asset atoms, decimals, mark and tick alignment.
14
+ - Keep authenticated order channels warm for durable dead-man heartbeats. A
15
+ placement whose guard cannot be proven is cancelled fail-closed.
16
+ - Expand `strata_autonomy` into a public-key-only runtime handshake covering
17
+ package version, mode, credential source, loaded/file/on-chain session
18
+ consistency, execution readiness, expiry, and clock skew.
19
+ - Preserve exact `retry_after_ms` values instead of retrying every transient
20
+ read after a fixed 150ms.
21
+
22
+ ## 0.2.17
23
+
24
+ - Consume the matching strategy-timed TypeScript SDK and allow an optional
25
+ zero cadence floor in advanced setup calls.
26
+ - Keep the local pairing listener alive longer and close its helper tab after
27
+ credentials are saved, with a branded Strata return page as the fallback.
28
+
6
29
  ## 0.2.16
7
30
 
8
31
  - Consume the matching TypeScript SDK 0.2.16 packaging-parity release. MCP
package/README.md CHANGED
@@ -1,20 +1,46 @@
1
+ <p align="center">
2
+ <a href="https://stratabook.app">
3
+ <img src="https://raw.githubusercontent.com/alsk1992/strata-mcp/main/assets/readme-hero.svg?v=20260826-2" alt="Strata MCP — The deepest book in DeFi" width="100%" />
4
+ </a>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://stratabook.app">Trade</a> ·
9
+ <a href="https://stratabook.org/docs/agent-mcp">Docs</a> ·
10
+ <a href="https://www.npmjs.com/package/@stratabook/mcp">npm</a> ·
11
+ <a href="https://api.stratabook.app/mcp">Hosted MCP</a>
12
+ </p>
13
+
1
14
  # Strata MCP
2
15
 
3
- Official MCP access to Strata and Sonar for every MCP-compatible client. It is
4
- not tied to Codex, Claude, Cursor, or any other host. The server delegates to
5
- `@stratabook/sdk` and follows Strata's live public policy.
16
+ Give any MCP-compatible agent direct access to Strata markets and Sonar. It
17
+ works across Claude Desktop, Cursor, Windsurf, Codex and any standards-based
18
+ MCP host.
19
+
20
+ Read-only access works immediately. No wallet, API key, session key, environment
21
+ variable or settings screen is required to explore markets, books, prices,
22
+ candles, trades, quotes and public maker data.
23
+
24
+ | How you connect | Best for | What stays private |
25
+ | --- | --- | --- |
26
+ | Hosted: `https://api.stratabook.app/mcp` | Instant reads, Sonar quotes and externally signed prepare/submit flows | The shared server never receives a session secret |
27
+ | Local: `npx -y @stratabook/mcp` | Reads now; optional one-call trading after `connect` | The session secret remains in a mode-0600 file on the user's machine |
28
+
29
+ Intent, Strand and Current controls are live. A connected local MCP can execute
30
+ session-backed trades, orders, TWAPs and Intent updates in one call. The hosted
31
+ shared MCP exposes Intent prepare/submit instead because it deliberately never
32
+ stores a user's session secret. Current tracks Strata's live mark automatically.
6
33
 
7
- The official hosted endpoint currently exposes market, exact-output, and
8
- asset-to-asset Sonar quotes, together with quote-bound execution tools.
9
- "Capability gated" simply means a tool disappears if Strata disables that
10
- operation live. It does **not** mean quotes need activation or user setup.
34
+ Tool availability follows Strata's live safety policy. If an operation is
35
+ paused, its tool disappears immediately; everyday reads and quotes require no
36
+ activation.
11
37
 
12
38
  The default tool mode is deliberately compact. Agents call the requested tool
13
39
  directly instead of burning discovery calls before a quote. Use
14
40
  `--mode advanced` (or `STRATA_MCP_MODE=advanced`) only when an integration
15
41
  needs the explicit challenge / prepare / submit protocol tools.
16
42
 
17
- ## Local stdio
43
+ ## Start locally
18
44
 
19
45
  Read-only use needs no wallet, key, autonomy setting, or environment variable:
20
46
 
@@ -36,9 +62,11 @@ returns to the live Control Center. Run the same command again to replace the
36
62
  old key atomically; the old key is revoked in the same signed transaction.
37
63
 
38
64
  There is no secret to paste into chat, no environment-variable screen, and no
39
- client config to edit after the read-only server is installed. Restart or
40
- refresh the MCP client after the browser confirms connection. Revoke the exact
41
- session and delete its local credential with:
65
+ client config to edit after the read-only server is installed. A running MCP
66
+ process detects the new, replaced, or removed local credential automatically;
67
+ no client restart is required for credential changes. A package-version upgrade
68
+ still requires the MCP host to launch the new process. Revoke the exact session
69
+ and delete its local credential with:
42
70
 
43
71
  ```sh
44
72
  npx -y @stratabook/mcp disconnect
@@ -80,8 +108,9 @@ The compact default exposes the tools ordinary users need:
80
108
  - `strata_quote` — accepts `0.1 SOL`, `20 USDC`, or `$20`; token atoms remain optional
81
109
  - `strata_portfolio` and `strata_market_making_status`
82
110
  - `strata_trade` — returns a live quote when trading is not connected, with one setup link; follows the user's session limits when connected
111
+ - `strata_order_execute` — understands instructions such as “sell 10% of available SOL at 3% above mark”; resolves exact atoms and tick rounding, submits through a warm order channel, and maintains fail-closed dead-man protection
83
112
  - `strata_market_making_prepare` and `strata_market_making_submit_and_wait`
84
- - `strata_autonomy` — reports whether optional trading is connected and the user's limits
113
+ - `strata_autonomy` — reports the user's limits plus MCP version/mode, loaded and on-chain session consistency, execution readiness, expiry, and clock skew
85
114
 
86
115
  Advanced mode additionally exposes the complete protocol surface, including:
87
116
 
@@ -167,8 +196,8 @@ original confirmed signature while the packet remains live.
167
196
  For maker funding, initialize the market Vault if needed, activate the Strand
168
197
  or Current, then deposit with `strata_vault_deposit`. The market keeps that
169
198
  available collateral while a control is live and returns it after the final
170
- control is disabled, exhausted, expired, or cancelled. Current follows Strata's
171
- live mark and needs no separate publisher transaction.
199
+ control is disabled, exhausted, expired, or cancelled. Current tracks Strata's
200
+ live mark automatically.
172
201
 
173
202
  The matching TypeScript package ships `strata-maker-conformance`. Its default
174
203
  `safe` mode exercises this hosted MCP's discovery, public maker reads, and
@@ -42,6 +42,15 @@ export interface SessionAutonomy {
42
42
  readonly config: AutonomyConfig;
43
43
  readonly dailyBudget: DailyUsdBudget;
44
44
  }
45
+ export type SessionAutonomyProvider = () => Promise<SessionAutonomy | null>;
46
+ /**
47
+ * Build a live session source for long-running MCP hosts. The credential file
48
+ * may be replaced by `strata-mcp connect` while the stdio process remains
49
+ * alive, so callers must not close over the first environment snapshot. Daily
50
+ * spend remains process-local and survives policy/file refreshes for the same
51
+ * owner + session pair.
52
+ */
53
+ export declare function liveSessionAutonomyProvider(loadEnvironment: () => Promise<Record<string, string | undefined>>): SessionAutonomyProvider;
45
54
  /** Parse the autonomy slider from a plain env bag; defaults to the calm `ask`. */
46
55
  export declare function parseAutonomyConfig(env: Record<string, string | undefined>): AutonomyConfig;
47
56
  /** Build the session-autonomy context from env, or null when no session key is set. */
@@ -26,6 +26,44 @@
26
26
  */
27
27
  import { StrataContractError, sessionSignerFromSecretKey, } from "@stratabook/sdk";
28
28
  export const AUTONOMY_LEVELS = ["ask", "limits", "instant"];
29
+ /**
30
+ * Build a live session source for long-running MCP hosts. The credential file
31
+ * may be replaced by `strata-mcp connect` while the stdio process remains
32
+ * alive, so callers must not close over the first environment snapshot. Daily
33
+ * spend remains process-local and survives policy/file refreshes for the same
34
+ * owner + session pair.
35
+ */
36
+ export function liveSessionAutonomyProvider(loadEnvironment) {
37
+ let fingerprint = null;
38
+ let current = null;
39
+ const budgets = new Map();
40
+ return async () => {
41
+ const env = await loadEnvironment();
42
+ const nextFingerprint = [
43
+ env.STRATA_OWNER_WALLET ?? "",
44
+ env.STRATA_SESSION_PUBLIC_KEY ?? "",
45
+ env.STRATA_SESSION_SECRET_KEY ?? "",
46
+ env.STRATA_AUTONOMY ?? "",
47
+ env.STRATA_AUTONOMY_MAX_USD_PER_TRADE ?? "",
48
+ env.STRATA_AUTONOMY_MAX_USD_PER_DAY ?? "",
49
+ env.STRATA_AUTONOMY_MARKETS ?? "",
50
+ ].join("\0");
51
+ if (fingerprint === nextFingerprint)
52
+ return current;
53
+ const parsed = await sessionAutonomyFromEnv(env);
54
+ if (parsed === null) {
55
+ fingerprint = nextFingerprint;
56
+ current = null;
57
+ return null;
58
+ }
59
+ const budgetKey = `${parsed.ownerWallet}:${parsed.signer.publicKey}`;
60
+ const dailyBudget = budgets.get(budgetKey) ?? parsed.dailyBudget;
61
+ budgets.set(budgetKey, dailyBudget);
62
+ current = { ...parsed, dailyBudget };
63
+ fingerprint = nextFingerprint;
64
+ return current;
65
+ };
66
+ }
29
67
  function positiveNumber(raw) {
30
68
  if (raw === undefined)
31
69
  return undefined;
package/dist/src/cli.js CHANGED
@@ -4,11 +4,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
5
  import { DEFAULT_API_BASE, StrataApiError, StrataClient } from "@stratabook/sdk";
6
6
  import { STRATA_ACTION_GRAPH, STRATA_AGENT_HARNESS, } from "./generated-harness.js";
7
- import { createStrataMcpServer, probeStrataMcpReadiness } from "./server.js";
8
- import { sessionAutonomyFromEnv } from "./autonomy.js";
7
+ import { createStrataMcpServer, probeStrataMcpReadiness, } from "./server.js";
8
+ import { liveSessionAutonomyProvider, } from "./autonomy.js";
9
9
  import { SERVER_VERSION } from "./version.js";
10
10
  import { friendlyApiError, humanQuoteAmount, parseToolMode } from "./usability.js";
11
- import { DEFAULT_PAIRING_WEB_BASE, loadTradingEnvironment, runLocalPairing, tradingCredentialsPath, } from "./pairing.js";
11
+ import { DEFAULT_PAIRING_WEB_BASE, loadTradingEnvironment, readTradingConnection, runLocalPairing, tradingCredentialsPath, } from "./pairing.js";
12
12
  function parse(argv) {
13
13
  const knownCommands = new Set(["doctor", "connect", "disconnect"]);
14
14
  const first = argv[0];
@@ -233,13 +233,33 @@ async function main() {
233
233
  });
234
234
  return;
235
235
  }
236
- const sessionEnv = await loadTradingEnvironment(process.env);
236
+ const runtimeEnvironment = options.credentialsFile === undefined
237
+ ? { ...process.env }
238
+ : { ...process.env, STRATA_MCP_CREDENTIALS_FILE: options.credentialsFile };
239
+ const sessionEnv = await loadTradingEnvironment(runtimeEnvironment);
237
240
  if (options.command === "doctor") {
238
241
  await runDoctor(options, sessionEnv);
239
242
  return;
240
243
  }
241
- const sessionAutonomy = await sessionAutonomyFromEnv(sessionEnv);
242
- const withSession = sessionAutonomy ? { ...options, sessionAutonomy } : options;
244
+ const sessionAutonomyProvider = liveSessionAutonomyProvider(() => loadTradingEnvironment(runtimeEnvironment));
245
+ const sessionAutonomy = await sessionAutonomyProvider();
246
+ const sessionCredentialDiagnostics = async () => {
247
+ const path = tradingCredentialsPath(runtimeEnvironment);
248
+ const stored = await readTradingConnection(path);
249
+ const explicit = Boolean(runtimeEnvironment.STRATA_SESSION_SECRET_KEY || runtimeEnvironment.STRATA_OWNER_WALLET);
250
+ return {
251
+ source: explicit ? "environment" : stored === null ? "none" : "file",
252
+ credentials_file: path,
253
+ credential_file_session_public_key: stored?.session_public_key ?? null,
254
+ };
255
+ };
256
+ const withSession = {
257
+ ...options,
258
+ sessionAutonomyProvider,
259
+ sessionCredentialDiagnostics,
260
+ persistentOrderGuards: options.transport === "stdio",
261
+ ...(sessionAutonomy === null ? {} : { sessionAutonomy }),
262
+ };
243
263
  if (sessionAutonomy) {
244
264
  process.stderr.write(`[strata-mcp] session autonomy: ${sessionAutonomy.config.level} `
245
265
  + `(wallet ${sessionAutonomy.ownerWallet.slice(0, 6)}…, session ${sessionAutonomy.signer.publicKey.slice(0, 6)}…, `
@@ -17,6 +17,8 @@ export interface PairingOptions {
17
17
  readonly env?: Readonly<Record<string, string | undefined>>;
18
18
  readonly nowMs?: () => number;
19
19
  }
20
+ /** Local success page: close the helper tab when allowed, otherwise return to Strata. */
21
+ export declare function pairingCompletionDocument(returnUrl: string): string;
20
22
  export declare function tradingCredentialsPath(env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform, home?: string): string;
21
23
  export declare function readTradingConnection(path?: string): Promise<StoredTradingConnection | null>;
22
24
  export declare function writeTradingConnection(connection: StoredTradingConnection, path?: string): Promise<void>;
@@ -1,14 +1,24 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
3
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { DEFAULT_API_BASE, generateSessionKeypair, } from "@stratabook/sdk";
8
8
  const PUBLIC_KEY_PATTERN = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
9
9
  const SECRET_KEY_PATTERN = /^[1-9A-HJ-NP-Za-km-z]{80,90}$/;
10
- const PAIRING_TIMEOUT_MS = 10 * 60_000;
10
+ const PAIRING_TIMEOUT_MS = 30 * 60_000;
11
11
  export const DEFAULT_PAIRING_WEB_BASE = "https://stratabook.app";
12
+ /** Local success page: close the helper tab when allowed, otherwise return to Strata. */
13
+ export function pairingCompletionDocument(returnUrl) {
14
+ const destination = JSON.stringify(returnUrl).replaceAll("<", "\\u003c");
15
+ return `<!doctype html>
16
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
17
+ <title>Agent connected · Strata</title><style>
18
+ :root{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#090b10;color:#f4f7fa;font:16px/1.5 system-ui,sans-serif}.card{max-width:34rem;margin:2rem;padding:2rem;border:1px solid #1b8596;border-radius:18px;background:linear-gradient(135deg,#0c171d,#100d1e)}h1{margin:.25rem 0 .5rem;font-size:1.7rem}p{margin:0;color:#aeb8c5}.mark{color:#34d6c4;font-weight:700;letter-spacing:.08em;text-transform:uppercase;font-size:.75rem}
19
+ </style></head><body><main class="card"><div class="mark">Strata</div><h1>Agent connected</h1><p>Your local credential is saved. You can close this tab.</p></main>
20
+ <script>const destination=${destination};window.close();setTimeout(()=>location.replace(destination),700);</script></body></html>`;
21
+ }
12
22
  function validPublicKey(value) {
13
23
  return typeof value === "string" && PUBLIC_KEY_PATTERN.test(value);
14
24
  }
@@ -60,13 +70,23 @@ export async function readTradingConnection(path = tradingCredentialsPath()) {
60
70
  }
61
71
  export async function writeTradingConnection(connection, path = tradingCredentialsPath()) {
62
72
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
63
- await writeFile(path, `${JSON.stringify(connection, null, 2)}\n`, {
64
- encoding: "utf8",
65
- mode: 0o600,
66
- });
67
- // `mode` is honored on creation. chmod also tightens a pre-existing file.
68
- if (process.platform !== "win32")
69
- await chmod(path, 0o600);
73
+ const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
74
+ try {
75
+ await writeFile(temporary, `${JSON.stringify(connection, null, 2)}\n`, {
76
+ encoding: "utf8",
77
+ mode: 0o600,
78
+ });
79
+ // `mode` is honored on creation. chmod also protects unusual umasks.
80
+ if (process.platform !== "win32")
81
+ await chmod(temporary, 0o600);
82
+ // Same-directory rename means the live MCP sees either the old complete
83
+ // credential or the new complete credential, never a partially written file.
84
+ await rename(temporary, path);
85
+ }
86
+ catch (error) {
87
+ await unlink(temporary).catch(() => undefined);
88
+ throw error;
89
+ }
70
90
  }
71
91
  export async function removeTradingConnection(path = tradingCredentialsPath()) {
72
92
  try {
@@ -198,13 +218,17 @@ async function waitForPairingCallback(sessionPublicKey, state, returnUrl, onComp
198
218
  finished = true;
199
219
  try {
200
220
  await onComplete(ownerWallet);
201
- response.writeHead(303, {
202
- location: returnUrl,
221
+ const document = pairingCompletionDocument(returnUrl);
222
+ response.writeHead(200, {
223
+ "content-type": "text/html; charset=utf-8",
224
+ "content-length": Buffer.byteLength(document),
203
225
  "cache-control": "no-store",
226
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",
227
+ "referrer-policy": "no-referrer",
204
228
  "x-content-type-options": "nosniff",
205
229
  "x-frame-options": "DENY",
206
230
  });
207
- response.end();
231
+ response.end(document);
208
232
  settle(ownerWallet);
209
233
  }
210
234
  catch (error) {
@@ -234,7 +258,7 @@ async function waitForPairingCallback(sessionPublicKey, state, returnUrl, onComp
234
258
  return;
235
259
  finished = true;
236
260
  server.close();
237
- reject(new Error("Strata pairing timed out after 10 minutes"));
261
+ reject(new Error("Strata pairing timed out after 30 minutes"));
238
262
  }, PAIRING_TIMEOUT_MS);
239
263
  timeout.unref();
240
264
  completion.finally(() => clearTimeout(timeout)).catch(() => undefined);
@@ -295,6 +319,6 @@ export async function runLocalPairing(options) {
295
319
  process.stdout.write("Waiting for the owner-wallet signature…\n");
296
320
  const ownerWallet = await callback.completion;
297
321
  process.stdout.write(options.action === "connect"
298
- ? `✓ Trading connected for ${ownerWallet}. Credentials saved privately at ${path}.\nRestart or refresh your MCP client.\n`
322
+ ? `✓ Trading connected for ${ownerWallet}. Credentials saved privately at ${path}.\nThis MCP release and newer pick it up automatically; restart only older clients.\n`
299
323
  : `✓ Session revoked for ${ownerWallet}. Local trading credentials removed.\n`);
300
324
  }
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StrataClient, StrataPlatformClient, type CapabilityCatalog } from "@stratabook/sdk";
3
- import { type SessionAutonomy } from "./autonomy.js";
3
+ import { type SessionAutonomy, type SessionAutonomyProvider } from "./autonomy.js";
4
4
  import { type StrataMcpToolMode } from "./usability.js";
5
5
  export interface StrataMcpOptions {
6
6
  apiBase?: string;
@@ -15,6 +15,21 @@ export interface StrataMcpOptions {
15
15
  * every trade is prepared for a human to sign. Built from env in the CLI.
16
16
  */
17
17
  sessionAutonomy?: SessionAutonomy;
18
+ /**
19
+ * Live source used by long-running hosts. When present it is consulted for
20
+ * every trading call, allowing `strata-mcp connect` to rotate the private
21
+ * credential file without restarting the MCP process.
22
+ */
23
+ sessionAutonomyProvider?: SessionAutonomyProvider;
24
+ /** Public-only credential metadata for the runtime health handshake. */
25
+ sessionCredentialDiagnostics?: () => Promise<SessionCredentialDiagnostics>;
26
+ /** False for stateless HTTP runtimes that cannot heartbeat a durable guard. */
27
+ persistentOrderGuards?: boolean;
28
+ }
29
+ export interface SessionCredentialDiagnostics {
30
+ readonly source: "environment" | "file" | "none";
31
+ readonly credentials_file: string | null;
32
+ readonly credential_file_session_public_key: string | null;
18
33
  }
19
34
  export interface StrataMcpRuntime {
20
35
  server: McpServer;
@@ -369,6 +369,315 @@ function orderOperationFromArgs(args) {
369
369
  }
370
370
  const PLATFORM_MARKET_PAGE_LIMIT = 100;
371
371
  const PLATFORM_MARKET_MAX_PAGES = 20;
372
+ /**
373
+ * Keeps authenticated order channels warm so a durable dead-man ticket can be
374
+ * heartbeated after the one MCP call returns. A session rotation closes every
375
+ * old channel; its already-armed ticket then fails closed instead of leaving
376
+ * orders unmanaged.
377
+ */
378
+ class WarmOrderConnectionPool {
379
+ platformClient;
380
+ connections = new Map();
381
+ sessionBinding = null;
382
+ constructor(platformClient) {
383
+ this.platformClient = platformClient;
384
+ }
385
+ reconcile(autonomy) {
386
+ const binding = autonomy === null
387
+ ? null
388
+ : `${autonomy.ownerWallet}:${autonomy.signer.publicKey}`;
389
+ if (binding === this.sessionBinding)
390
+ return;
391
+ this.close();
392
+ this.sessionBinding = binding;
393
+ }
394
+ async get(marketId, autonomy) {
395
+ this.reconcile(autonomy);
396
+ let pending = this.connections.get(marketId);
397
+ if (!pending) {
398
+ pending = this.platformClient.orders.connect(marketId, autonomy.ownerWallet, autonomy.signer, {
399
+ onError: (error) => {
400
+ process.stderr.write(`[strata-mcp] guarded order channel failed for ${marketId}: ${safeMessage(error)}\n`);
401
+ },
402
+ }).then(async (connection) => {
403
+ await connection.ready;
404
+ return connection;
405
+ });
406
+ this.connections.set(marketId, pending);
407
+ pending.catch(() => {
408
+ if (this.connections.get(marketId) === pending)
409
+ this.connections.delete(marketId);
410
+ });
411
+ }
412
+ return pending;
413
+ }
414
+ close() {
415
+ for (const pending of this.connections.values()) {
416
+ pending.then((connection) => connection.close()).catch(() => undefined);
417
+ }
418
+ this.connections.clear();
419
+ this.sessionBinding = null;
420
+ }
421
+ }
422
+ /** Resolve a human limit-order instruction from one coherent public snapshot. */
423
+ async function resolveFriendlyOrderIntent(platformClient, autonomy, args, nowMs) {
424
+ const percentageHundredths = Math.round(args.availablePercent * 100);
425
+ if (!Number.isFinite(args.availablePercent)
426
+ || args.availablePercent <= 0
427
+ || args.availablePercent > 100
428
+ || Math.abs(args.availablePercent * 100 - percentageHundredths) > 1e-8) {
429
+ throw new TypeError("availablePercent must be greater than 0 and at most 100, with at most two decimals");
430
+ }
431
+ if (!Number.isSafeInteger(args.markOffsetBps) || args.markOffsetBps <= -10_000) {
432
+ throw new TypeError("markOffsetBps must be an integer greater than -10000");
433
+ }
434
+ const market = await platformClient.markets.resolve(args.market);
435
+ if (market.status !== "active" || !market.available_actions.includes("place_order")) {
436
+ throw new Error(`${market.label} is not currently accepting resting orders`);
437
+ }
438
+ const [mark, status, fees, portfolio, assetsPage] = await Promise.all([
439
+ platformClient.marketData.mark(market.market_id),
440
+ platformClient.books.status(market.market_id),
441
+ platformClient.books.fees(market.market_id),
442
+ platformClient.account.portfolio(autonomy.ownerWallet),
443
+ platformClient.assets.list({ limit: 200 }),
444
+ ]);
445
+ if (mark.stale || mark.price_atoms_per_base_unit === null) {
446
+ throw new Error(`${market.label} does not have a fresh mark`);
447
+ }
448
+ const baseAsset = assetsPage.assets.find((asset) => asset.asset_id === market.base_asset_id)
449
+ ?? await platformClient.assets.resolve(market.base_asset_id);
450
+ const quoteAsset = assetsPage.assets.find((asset) => asset.asset_id === market.quote_asset_id)
451
+ ?? await platformClient.assets.resolve(market.quote_asset_id);
452
+ if (mark.quote_decimals !== quoteAsset.decimals) {
453
+ throw new Error(`${market.label} mark decimals do not match its quote asset`);
454
+ }
455
+ const tick = BigInt(status.tick_size_atoms);
456
+ const markAtoms = BigInt(mark.price_atoms_per_base_unit);
457
+ if (tick <= 0n || markAtoms <= 0n)
458
+ throw new Error(`${market.label} returned invalid price metadata`);
459
+ const factor = BigInt(10_000 + args.markOffsetBps);
460
+ const priceDenominator = 10000n * tick;
461
+ const priceNumerator = markAtoms * factor;
462
+ const priceTicks = args.side === "sell"
463
+ ? (priceNumerator + priceDenominator - 1n) / priceDenominator
464
+ : priceNumerator / priceDenominator;
465
+ const limitPriceAtoms = priceTicks * tick;
466
+ if (limitPriceAtoms <= 0n)
467
+ throw new Error("the resolved limit price is below one tick");
468
+ const inputAssetId = args.side === "sell" ? market.base_asset_id : market.quote_asset_id;
469
+ const inputBalance = portfolio.balances.find((balance) => balance.asset_id === inputAssetId);
470
+ if (!inputBalance)
471
+ throw new Error(`no ${args.side === "sell" ? baseAsset.symbol : quoteAsset.symbol} balance is available`);
472
+ const availableAtoms = BigInt(inputBalance.available_atoms);
473
+ const budgetAtoms = availableAtoms * BigInt(percentageHundredths) / 10000n;
474
+ const baseUnit = 10n ** BigInt(baseAsset.decimals);
475
+ const positiveMakerFeeBps = BigInt(Math.max(0, fees.passive_maker_fee_bps));
476
+ const sizeAtoms = args.side === "sell"
477
+ ? budgetAtoms
478
+ : budgetAtoms * baseUnit * 10000n
479
+ / (limitPriceAtoms * (10000n + positiveMakerFeeBps));
480
+ if (sizeAtoms <= 0n)
481
+ throw new Error("the requested percentage resolves below the minimum order size");
482
+ if (sizeAtoms < BigInt(status.minimum_order_size_atoms)) {
483
+ throw new Error(`the resolved size is below ${status.minimum_order_size_atoms} base atoms`);
484
+ }
485
+ // Autonomy ceilings are risk limits, so value the order at the less
486
+ // favourable of its live mark and limit instead of allowing an aggressive
487
+ // offset to understate the amount the session may spend.
488
+ const riskPriceAtoms = markAtoms > limitPriceAtoms ? markAtoms : limitPriceAtoms;
489
+ const notionalQuoteAtoms = sizeAtoms * riskPriceAtoms / baseUnit;
490
+ const notionalUsd = Number(notionalQuoteAtoms) / 10 ** quoteAsset.decimals;
491
+ if (!Number.isFinite(notionalUsd) || notionalUsd <= 0) {
492
+ throw new Error("the resolved order notional is invalid");
493
+ }
494
+ const clientOrderId = args.clientOrderId
495
+ ?? `mcp-${nowMs().toString(36)}-${autonomy.signer.publicKey.slice(0, 8)}`;
496
+ return {
497
+ marketId: market.market_id,
498
+ clientOrderId,
499
+ side: args.side,
500
+ orderType: args.orderType ?? "post_only",
501
+ limitPriceAtoms: limitPriceAtoms.toString(),
502
+ sizeAtoms: sizeAtoms.toString(),
503
+ notionalUsd,
504
+ resolution: {
505
+ market: market.label,
506
+ market_id: market.market_id,
507
+ client_order_id: clientOrderId,
508
+ side: args.side,
509
+ available_percent: args.availablePercent,
510
+ input_asset: args.side === "sell" ? baseAsset.symbol : quoteAsset.symbol,
511
+ input_available_atoms: availableAtoms.toString(),
512
+ input_budget_atoms: budgetAtoms.toString(),
513
+ size_atoms: sizeAtoms.toString(),
514
+ size_display: `${formatAtoms(sizeAtoms.toString(), baseAsset.decimals)} ${baseAsset.symbol}`,
515
+ mark_price_atoms: markAtoms.toString(),
516
+ mark_offset_bps: args.markOffsetBps,
517
+ limit_price_atoms: limitPriceAtoms.toString(),
518
+ risk_price_atoms: riskPriceAtoms.toString(),
519
+ risk_notional_usd: notionalUsd,
520
+ tick_size_atoms: tick.toString(),
521
+ portfolio_observed_slot: portfolio.observed_slot,
522
+ portfolio_observed_at_ms: portfolio.observed_at_ms,
523
+ mark_server_time_ms: mark.server_time_ms,
524
+ },
525
+ };
526
+ }
527
+ async function waitForOrderSubmission(connection, orderControlId, idempotencyKey) {
528
+ let status = await connection.status(orderControlId, idempotencyKey);
529
+ for (let attempt = 0; status.status === "submitting" && attempt < 12; attempt += 1) {
530
+ await new Promise((resolve) => setTimeout(resolve, 100));
531
+ status = await connection.status(orderControlId, idempotencyKey);
532
+ }
533
+ return status;
534
+ }
535
+ /**
536
+ * Existing protocol cannot pre-sign a cancel for an order that does not exist
537
+ * yet. Close that gap operationally: confirm the placement, arm immediately on
538
+ * the same warm socket, and cancel the new order if arming cannot be proven.
539
+ */
540
+ async function executeGuardedOrder(connection, autonomy, operation, idempotencyKey, deadManTimeoutMs) {
541
+ const receipt = await connection.execute({ operation, idempotencyKey });
542
+ let status = null;
543
+ let statusError = null;
544
+ try {
545
+ status = await waitForOrderSubmission(connection, receipt.order_control_id, idempotencyKey);
546
+ }
547
+ catch (error) {
548
+ // The order may already be live. Continue to the guard instead of leaving
549
+ // it unmanaged merely because the confirmation read was throttled.
550
+ statusError = safeMessage(error);
551
+ }
552
+ if (status?.status === "failed") {
553
+ return {
554
+ ok: false,
555
+ code: "order_submission_failed",
556
+ message: `Order submission failed: ${status.failure_code ?? "unknown"}.`,
557
+ details: { receipt, status },
558
+ };
559
+ }
560
+ let guardError;
561
+ for (let attempt = 0; attempt < 2; attempt += 1) {
562
+ try {
563
+ const deadMan = await connection.armDeadMan({
564
+ timeoutMs: deadManTimeoutMs,
565
+ idempotencyKey: `guard-${receipt.order_control_id}`,
566
+ });
567
+ return { ok: true, receipt, status, statusError, deadMan };
568
+ }
569
+ catch (error) {
570
+ guardError = error;
571
+ if (attempt === 0) {
572
+ const delay = error instanceof StrataApiError ? error.retryAfterMs ?? 100 : 100;
573
+ // No guard exists yet. A long provider retry window must not leave the
574
+ // freshly placed order exposed while we wait; cancel it immediately.
575
+ if (delay > 250)
576
+ break;
577
+ await new Promise((resolve) => setTimeout(resolve, delay));
578
+ }
579
+ }
580
+ }
581
+ const orderId = receipt.order_ids[0];
582
+ if (orderId) {
583
+ try {
584
+ const cancellation = await connection.execute({
585
+ operation: { action: "cancel", ownerWallet: autonomy.ownerWallet, orderId },
586
+ idempotencyKey: `guard-failed-cancel-${receipt.order_control_id}`,
587
+ });
588
+ let cancellationStatus;
589
+ try {
590
+ cancellationStatus = await waitForOrderSubmission(connection, cancellation.order_control_id, `guard-failed-cancel-${receipt.order_control_id}`);
591
+ }
592
+ catch (cancelStatusError) {
593
+ return {
594
+ ok: false,
595
+ code: "dead_man_setup_ambiguous",
596
+ message: "The order guard failed and Strata submitted a cancellation, but could not prove that cancellation completed.",
597
+ details: {
598
+ receipt,
599
+ status,
600
+ status_error: statusError,
601
+ cancellation,
602
+ guard_error: safeMessage(guardError),
603
+ cancellation_status_error: safeMessage(cancelStatusError),
604
+ },
605
+ };
606
+ }
607
+ if (cancellationStatus.status !== "submitted") {
608
+ return {
609
+ ok: false,
610
+ code: "dead_man_setup_ambiguous",
611
+ message: "The order guard failed and its fail-closed cancellation was not confirmed.",
612
+ details: { receipt, status, status_error: statusError, cancellation, cancellationStatus },
613
+ };
614
+ }
615
+ return {
616
+ ok: false,
617
+ code: "dead_man_setup_failed",
618
+ message: "The order was submitted but its dead-man guard could not be proven, so Strata cancelled it fail-closed.",
619
+ details: {
620
+ receipt,
621
+ status,
622
+ status_error: statusError,
623
+ cancellation,
624
+ cancellation_status: cancellationStatus,
625
+ guard_error: safeMessage(guardError),
626
+ },
627
+ };
628
+ }
629
+ catch (cancelError) {
630
+ return {
631
+ ok: false,
632
+ code: "dead_man_setup_ambiguous",
633
+ message: "The order was submitted, but neither its dead-man guard nor fail-closed cancellation could be proven.",
634
+ details: {
635
+ receipt,
636
+ status,
637
+ status_error: statusError,
638
+ guard_error: safeMessage(guardError),
639
+ cancellation_error: safeMessage(cancelError),
640
+ },
641
+ };
642
+ }
643
+ }
644
+ return {
645
+ ok: false,
646
+ code: "dead_man_setup_ambiguous",
647
+ message: "The order was submitted without a returned order ID, so its guard could not be proven.",
648
+ details: { receipt, status, status_error: statusError, guard_error: safeMessage(guardError) },
649
+ };
650
+ }
651
+ async function observePlacedOrderLifecycle(platformClient, ownerWallet, orderIds) {
652
+ try {
653
+ const portfolio = await platformClient.account.portfolio(ownerWallet);
654
+ const orders = orderIds.map((orderId) => {
655
+ const visible = portfolio.open_orders.find((order) => order.order_id === orderId);
656
+ return { order_id: orderId, state: visible?.state ?? "pending_visibility" };
657
+ });
658
+ const state = orders.length > 0 && orders.every((order) => order.state === "open" || order.state === "partially_filled")
659
+ ? orders.every((order) => order.state === "open") ? "confirmed_open" : "partially_filled"
660
+ : "confirmed_pending_visibility";
661
+ return {
662
+ state,
663
+ orders,
664
+ observed_at_ms: portfolio.observed_at_ms,
665
+ observed_slot: portfolio.observed_slot,
666
+ unavailable_market_ids: portfolio.unavailable_market_ids,
667
+ error: null,
668
+ };
669
+ }
670
+ catch (error) {
671
+ return {
672
+ state: "confirmed_pending_visibility",
673
+ orders: orderIds.map((orderId) => ({ order_id: orderId, state: "pending_visibility" })),
674
+ observed_at_ms: null,
675
+ observed_slot: null,
676
+ unavailable_market_ids: [],
677
+ error: safeMessage(error),
678
+ };
679
+ }
680
+ }
372
681
  /**
373
682
  * Every live platform market keyed by label, so the tool can hand agents the
374
683
  * opaque `market_id` (and asset ids) that every by-market tool takes. A
@@ -404,6 +713,8 @@ async function platformMarketIdentities(platformClient) {
404
713
  export async function createStrataMcpServer(options = {}) {
405
714
  const client = strataClient(options);
406
715
  const toolMode = options.toolMode ?? "simple";
716
+ const autonomyForCall = options.sessionAutonomyProvider
717
+ ?? (async () => options.sessionAutonomy ?? null);
407
718
  const platformClient = options.platformClient ?? new StrataPlatformClient({
408
719
  apiBase: options.apiBase,
409
720
  timeoutMs: options.timeoutMs,
@@ -939,7 +1250,7 @@ export async function createStrataMcpServer(options = {}) {
939
1250
  .max(4)
940
1251
  .optional(),
941
1252
  expiresAtMs: z.number().int().positive().optional(),
942
- minimumIntervalSeconds: z.number().int().min(1).max(86_400).optional(),
1253
+ minimumIntervalSeconds: z.number().int().min(0).max(86_400).optional(),
943
1254
  maximumToleranceBps: z.number().int().min(1).max(1_000).optional(),
944
1255
  },
945
1256
  annotations: {
@@ -1444,7 +1755,8 @@ export async function createStrataMcpServer(options = {}) {
1444
1755
  amountInAtoms: resolvedAtoms,
1445
1756
  maximumToleranceBps,
1446
1757
  }));
1447
- if (!options.sessionAutonomy) {
1758
+ const autonomy = await autonomyForCall();
1759
+ if (!autonomy) {
1448
1760
  return toolResult({
1449
1761
  executed: false,
1450
1762
  reason: "trading_not_connected",
@@ -1464,18 +1776,18 @@ export async function createStrataMcpServer(options = {}) {
1464
1776
  : null;
1465
1777
  const resolver = new MarketMetaResolver(platformClient, async () => sonarMarkets, () => Date.now());
1466
1778
  const marketId = sonar ? await resolver.idForLabel(sonar.label) : null;
1467
- const decision = decideAutonomy(options.sessionAutonomy, marketId ?? "", notional, Date.now());
1779
+ const decision = decideAutonomy(autonomy, marketId ?? "", notional, Date.now());
1468
1780
  if (!decision.allow) {
1469
1781
  return toolResult({ executed: false, reason: decision.reason, quote: freshQuote }, `${decision.reason} The live quote is attached; no transaction was sent.`);
1470
1782
  }
1471
1783
  const receipt = await client.executeQuote({
1472
1784
  quote: freshQuote,
1473
- ownerWallet: options.sessionAutonomy.ownerWallet,
1474
- signer: options.sessionAutonomy.signer,
1785
+ ownerWallet: autonomy.ownerWallet,
1786
+ signer: autonomy.signer,
1475
1787
  ...(idempotencyKey === undefined ? {} : { idempotencyKey }),
1476
1788
  });
1477
1789
  if (notional !== null)
1478
- options.sessionAutonomy.dailyBudget.record(notional, Date.now());
1790
+ autonomy.dailyBudget.record(notional, Date.now());
1479
1791
  return toolResult({ executed: true, receipt, notional_usd: notional }, `Executed ${side} ${inputDisplay} as ${receipt.signature}.`);
1480
1792
  }));
1481
1793
  registerTool("strata_swap_quote", {
@@ -2135,7 +2447,8 @@ export async function createStrataMcpServer(options = {}) {
2135
2447
  });
2136
2448
  return toolResult(response, `Submitted IntentBook control as ${response.signature}.`);
2137
2449
  }));
2138
- registerAutonomyTools(registerTool, client, platformClient, options.sessionAutonomy, () => typeof Date !== "undefined" ? Date.now() : 0);
2450
+ const orderConnections = new WarmOrderConnectionPool(platformClient);
2451
+ registerAutonomyTools(registerTool, client, platformClient, autonomyForCall, orderConnections, toolMode, options.persistentOrderGuards ?? true, options.sessionCredentialDiagnostics, () => typeof Date !== "undefined" ? Date.now() : 0);
2139
2452
  void [
2140
2453
  markets,
2141
2454
  quote,
@@ -2161,10 +2474,12 @@ export async function createStrataMcpServer(options = {}) {
2161
2474
  const refresh = async () => {
2162
2475
  if (closed)
2163
2476
  return;
2164
- const [catalog, platformCatalog] = await Promise.all([
2477
+ const [catalog, platformCatalog, autonomy] = await Promise.all([
2165
2478
  client.capabilities(),
2166
2479
  platformClient.discovery.read(),
2480
+ autonomyForCall(),
2167
2481
  ]);
2482
+ orderConnections.reconcile(autonomy);
2168
2483
  applyToolAvailability(registeredTools, catalog, platformCatalog, toolMode);
2169
2484
  };
2170
2485
  const timer = setInterval(() => {
@@ -2179,11 +2494,12 @@ export async function createStrataMcpServer(options = {}) {
2179
2494
  close: async () => {
2180
2495
  closed = true;
2181
2496
  clearInterval(timer);
2497
+ orderConnections.close();
2182
2498
  await server.close();
2183
2499
  },
2184
2500
  };
2185
2501
  }
2186
- function registerAutonomyTools(registerTool, client, platformClient, autonomy, nowMs) {
2502
+ function registerAutonomyTools(registerTool, client, platformClient, autonomyProvider, orderConnections, toolMode, persistentOrderGuards, credentialDiagnostics, nowMs) {
2187
2503
  // Always present, always read-only: the agent may show the slider and offer
2188
2504
  // to change it, but nothing it calls can raise its own autonomy.
2189
2505
  registerTool("strata_autonomy", {
@@ -2199,8 +2515,16 @@ function registerAutonomyTools(registerTool, client, platformClient, autonomy, n
2199
2515
  openWorldHint: false,
2200
2516
  },
2201
2517
  }, async () => {
2518
+ let autonomy = null;
2519
+ let runtimeError = null;
2520
+ try {
2521
+ autonomy = await autonomyProvider();
2522
+ }
2523
+ catch (error) {
2524
+ runtimeError = safeMessage(error);
2525
+ }
2202
2526
  const howToChange = {
2203
- setup: "Open the Agents page, connect the owner wallet, register once, then copy the MCP trading config into your client's local settings.",
2527
+ setup: "Run `npx -y @stratabook/mcp connect`; it opens the Agents page, registers the locally generated public key, and the running MCP hot-loads the private credential.",
2204
2528
  agents_page: "https://stratabook.app/agents",
2205
2529
  generic_clients: "Claude Desktop, Cursor, Windsurf, Codex, or any local stdio MCP host",
2206
2530
  note: "Read-only tools need none of this. Only the user changes trading authority; an agent can never raise its own level.",
@@ -2212,12 +2536,61 @@ function registerAutonomyTools(registerTool, client, platformClient, autonomy, n
2212
2536
  session_env: "STRATA_SESSION_SECRET_KEY + STRATA_OWNER_WALLET (register the key on the Agents page)",
2213
2537
  },
2214
2538
  };
2539
+ let credential = {
2540
+ source: "none",
2541
+ credentials_file: null,
2542
+ credential_file_session_public_key: null,
2543
+ };
2544
+ try {
2545
+ credential = await credentialDiagnostics?.() ?? credential;
2546
+ }
2547
+ catch (error) {
2548
+ runtimeError ??= safeMessage(error);
2549
+ }
2215
2550
  if (!autonomy) {
2216
- return toolResult({ session_configured: false, level: "ask", how_to_change: howToChange }, "Read-only is ready. Trading is not connected, so I cannot send transactions. "
2217
- + "If you want trading, open https://stratabook.app/agents and copy its MCP trading config "
2218
- + "into your client; never paste the session secret into chat.");
2551
+ return toolResult({
2552
+ session_configured: false,
2553
+ level: "ask",
2554
+ runtime: {
2555
+ mcp_version: SERVER_VERSION,
2556
+ tool_mode: toolMode,
2557
+ credential_source: credential.source,
2558
+ credentials_file: credential.credentials_file,
2559
+ loaded_session_public_key: null,
2560
+ credential_file_session_public_key: credential.credential_file_session_public_key,
2561
+ session_key_consistent: null,
2562
+ on_chain: null,
2563
+ trading_tools_usable: false,
2564
+ diagnostic_error: runtimeError,
2565
+ credential_reload_supported: true,
2566
+ credential_restart_required: false,
2567
+ package_upgrade_requires_process_restart: true,
2568
+ },
2569
+ how_to_change: howToChange,
2570
+ }, "Read-only is ready. Trading is not connected, so I cannot send transactions. "
2571
+ + "If you want trading, run `npx -y @stratabook/mcp connect`; never paste the session secret into chat.");
2219
2572
  }
2220
2573
  const { config } = autonomy;
2574
+ let onChain = null;
2575
+ if (platformClient.vault?.status) {
2576
+ try {
2577
+ const status = await platformClient.vault.status({
2578
+ walletAddress: autonomy.ownerWallet,
2579
+ sessionPublicKey: autonomy.signer.publicKey,
2580
+ });
2581
+ onChain = {
2582
+ vault_state: status.state,
2583
+ session_public_key: status.session?.session_public_key ?? null,
2584
+ session_state: status.session?.state ?? "absent",
2585
+ market_execution_ready: status.session?.market_execution_ready ?? false,
2586
+ expires_at_ms: status.session?.expires_at_ms ?? null,
2587
+ clock_skew_ms: status.server_time_ms - nowMs(),
2588
+ };
2589
+ }
2590
+ catch (error) {
2591
+ runtimeError ??= safeMessage(error);
2592
+ }
2593
+ }
2221
2594
  const spentToday = autonomy.dailyBudget.spentToday(nowMs());
2222
2595
  const state = {
2223
2596
  session_configured: true,
@@ -2231,6 +2604,30 @@ function registerAutonomyTools(registerTool, client, platformClient, autonomy, n
2231
2604
  ? null
2232
2605
  : Number(Math.max(0, config.maxUsdPerDay - spentToday).toFixed(2)),
2233
2606
  allowed_market_ids: config.allowedMarketIds ?? null,
2607
+ runtime: {
2608
+ mcp_version: SERVER_VERSION,
2609
+ tool_mode: toolMode,
2610
+ credential_source: credential.source,
2611
+ credentials_file: credential.credentials_file,
2612
+ loaded_session_public_key: autonomy.signer.publicKey,
2613
+ credential_file_session_public_key: credential.credential_file_session_public_key,
2614
+ session_key_consistent: credential.source === "file"
2615
+ ? credential.credential_file_session_public_key === autonomy.signer.publicKey
2616
+ : credential.source === "environment"
2617
+ ? true
2618
+ : null,
2619
+ on_chain: onChain,
2620
+ trading_tools_usable: onChain === null
2621
+ ? null
2622
+ : onChain.vault_state === "active"
2623
+ && onChain.session_state === "active"
2624
+ && onChain.market_execution_ready === true
2625
+ && onChain.session_public_key === autonomy.signer.publicKey,
2626
+ diagnostic_error: runtimeError,
2627
+ credential_reload_supported: true,
2628
+ credential_restart_required: false,
2629
+ package_upgrade_requires_process_restart: true,
2630
+ },
2234
2631
  how_to_change: howToChange,
2235
2632
  };
2236
2633
  const summary = config.level === "instant"
@@ -2241,8 +2638,6 @@ function registerAutonomyTools(registerTool, client, platformClient, autonomy, n
2241
2638
  : "Autonomy: ask — I prepare trades but never sign them; you sign each one.";
2242
2639
  return toolResult(state, summary);
2243
2640
  });
2244
- if (!autonomy)
2245
- return;
2246
2641
  const resolver = new MarketMetaResolver(platformClient, async () => (await client.markets()).markets, nowMs);
2247
2642
  const markFor = async (marketId) => {
2248
2643
  const mark = await platformClient.marketData.mark(marketId);
@@ -2253,6 +2648,7 @@ function registerAutonomyTools(registerTool, client, platformClient, autonomy, n
2253
2648
  };
2254
2649
  };
2255
2650
  const refuse = (reason, prepared, summary) => toolResult({ executed: false, reason, prepared }, summary);
2651
+ const tradingNotConnected = () => toolError("trading_not_connected", "Trading is not connected. Run strata-mcp connect; this running MCP will pick up the private credential automatically.", false);
2256
2652
  // ── one-shot existing IntentBook seat control ──────────────────────────────
2257
2653
  registerTool("strata_market_making_intent_execute", {
2258
2654
  title: "Execute Strata IntentBook control",
@@ -2274,6 +2670,9 @@ function registerAutonomyTools(registerTool, client, platformClient, autonomy, n
2274
2670
  openWorldHint: true,
2275
2671
  },
2276
2672
  }, async (args) => guardedTool(client, "mm.intent.manage", async () => {
2673
+ const autonomy = await autonomyProvider();
2674
+ if (!autonomy)
2675
+ return tradingNotConnected();
2277
2676
  let operation;
2278
2677
  if (args.action === "revoke") {
2279
2678
  operation = { action: "revoke", ownerWallet: autonomy.ownerWallet };
@@ -2332,6 +2731,9 @@ function registerAutonomyTools(registerTool, client, platformClient, autonomy, n
2332
2731
  openWorldHint: true,
2333
2732
  },
2334
2733
  }, async (args) => guardedTool(client, "trade.submit", async () => {
2734
+ const autonomy = await autonomyProvider();
2735
+ if (!autonomy)
2736
+ return tradingNotConnected();
2335
2737
  const quote = await retryReadOnce(() => client.quote({
2336
2738
  market: args.market,
2337
2739
  side: args.side,
@@ -2360,17 +2762,27 @@ function registerAutonomyTools(registerTool, client, platformClient, autonomy, n
2360
2762
  // ── one-shot order control (place / cancel / replace / batch) ─────────────
2361
2763
  registerTool("strata_order_execute", {
2362
2764
  title: "Execute a Strata order control",
2363
- description: "Place, cancel, replace, or batch orders and, within the autonomy slider, sign with the session "
2364
- + "key and submit in one call. Owner wallet and session key come from the configured session. "
2365
- + "Under \"ask\" (or over a \"limits\" ceiling) it prepares the transaction and asks you to sign.",
2765
+ description: "Place a human limit order such as 'sell 10% of available SOL at mark +3%' in one call, or use "
2766
+ + "the exact-atom cancel/replace/batch controls. Strata resolves the balance, market, mark, decimals "
2767
+ + "and tick grid internally. Autonomous placements keep a warm dead-man guard; if it cannot be "
2768
+ + "established, the new order is cancelled fail-closed.",
2366
2769
  inputSchema: {
2367
- marketId: z.string().regex(/^market_[0-9a-f]{32}$/),
2770
+ market: z.string().min(1).max(128).optional()
2771
+ .describe("Friendly market label such as SOL/USDC (also accepts a market ID)."),
2772
+ marketId: z.string().regex(/^market_[0-9a-f]{32}$/).optional()
2773
+ .describe("Advanced alternative to market."),
2368
2774
  action: z.enum(["place", "cancel", "cancel_all", "replace", "batch"]),
2369
2775
  clientOrderId: z.string().min(1).max(64).regex(/^[A-Za-z0-9._-]+$/).optional(),
2370
2776
  side: z.enum(["buy", "sell"]).optional(),
2371
2777
  orderType: z.enum(["good_until_cancelled", "post_only"]).optional(),
2372
2778
  limitPriceAtoms: z.string().regex(/^[1-9][0-9]*$/).max(20).optional(),
2373
2779
  sizeAtoms: z.string().regex(/^[1-9][0-9]*$/).max(20).optional(),
2780
+ availablePercent: z.number().positive().max(100).optional()
2781
+ .describe("Friendly placement size: percentage of available input balance, for example 10."),
2782
+ markOffsetBps: z.number().int().min(-9_999).max(100_000).optional()
2783
+ .describe("Signed offset from the current mark in basis points; +300 is 3% above mark."),
2784
+ deadManTimeoutMs: z.number().int().min(1_000).max(30_000).optional()
2785
+ .describe("Guard timeout for an autonomous placement; friendly orders default to 10000ms."),
2374
2786
  orderId: z.string().regex(/^order_[0-9a-f]{32}$/).optional(),
2375
2787
  operations: z.array(z.object({
2376
2788
  action: z.enum(["place", "cancel", "replace"]),
@@ -2390,34 +2802,136 @@ function registerAutonomyTools(registerTool, client, platformClient, autonomy, n
2390
2802
  openWorldHint: true,
2391
2803
  },
2392
2804
  }, async (args) => guardedTool(client, "orders.submit", async () => {
2805
+ const autonomy = await autonomyProvider();
2806
+ if (!autonomy)
2807
+ return tradingNotConnected();
2808
+ const friendly = args.availablePercent !== undefined || args.markOffsetBps !== undefined;
2809
+ let resolved = null;
2810
+ let marketId = args.marketId;
2811
+ let clientOrderId = args.clientOrderId;
2812
+ let side = args.side;
2813
+ let orderType = args.orderType;
2814
+ let limitPriceAtoms = args.limitPriceAtoms;
2815
+ let sizeAtoms = args.sizeAtoms;
2816
+ if (friendly) {
2817
+ if (args.action !== "place"
2818
+ || args.market === undefined
2819
+ || args.side === undefined
2820
+ || args.availablePercent === undefined
2821
+ || args.limitPriceAtoms !== undefined
2822
+ || args.sizeAtoms !== undefined
2823
+ || args.marketId !== undefined) {
2824
+ return toolError("invalid_order_intent", "Friendly placement requires action=place, market, side and availablePercent; omit marketId, limitPriceAtoms and sizeAtoms.", false);
2825
+ }
2826
+ try {
2827
+ resolved = await resolveFriendlyOrderIntent(platformClient, autonomy, {
2828
+ market: args.market,
2829
+ side: args.side,
2830
+ availablePercent: args.availablePercent,
2831
+ markOffsetBps: args.markOffsetBps ?? 0,
2832
+ ...(args.clientOrderId === undefined ? {} : { clientOrderId: args.clientOrderId }),
2833
+ ...(args.orderType === undefined ? {} : { orderType: args.orderType }),
2834
+ }, nowMs);
2835
+ }
2836
+ catch (error) {
2837
+ if (error instanceof StrataApiError)
2838
+ throw error;
2839
+ return toolError("order_intent_unavailable", safeMessage(error), true);
2840
+ }
2841
+ marketId = resolved.marketId;
2842
+ clientOrderId = resolved.clientOrderId;
2843
+ side = resolved.side;
2844
+ orderType = resolved.orderType;
2845
+ limitPriceAtoms = resolved.limitPriceAtoms;
2846
+ sizeAtoms = resolved.sizeAtoms;
2847
+ }
2848
+ else if (marketId === undefined && args.market !== undefined) {
2849
+ try {
2850
+ marketId = (await platformClient.markets.resolve(args.market)).market_id;
2851
+ }
2852
+ catch (error) {
2853
+ if (error instanceof StrataApiError)
2854
+ throw error;
2855
+ return toolError("market_unavailable", safeMessage(error), true);
2856
+ }
2857
+ }
2858
+ if (marketId === undefined) {
2859
+ return toolError("invalid_request", "Give market or marketId.", false);
2860
+ }
2393
2861
  const challenge = orderOperationFromArgs({
2394
- ...args,
2862
+ action: args.action,
2863
+ ...(clientOrderId === undefined ? {} : { clientOrderId }),
2864
+ ...(side === undefined ? {} : { side }),
2865
+ ...(orderType === undefined ? {} : { orderType }),
2866
+ ...(limitPriceAtoms === undefined ? {} : { limitPriceAtoms }),
2867
+ ...(sizeAtoms === undefined ? {} : { sizeAtoms }),
2868
+ ...(args.orderId === undefined ? {} : { orderId: args.orderId }),
2869
+ ...(args.operations === undefined ? {} : { operations: args.operations }),
2395
2870
  ownerWallet: autonomy.ownerWallet,
2396
2871
  sessionPublicKey: autonomy.signer.publicKey,
2397
2872
  });
2398
2873
  if ("content" in challenge)
2399
2874
  return challenge;
2400
2875
  // A place/replace risks new base; a cancel reduces it (notional 0).
2401
- const baseAtoms = (args.action === "place" || args.action === "replace") && args.sizeAtoms !== undefined
2402
- ? BigInt(args.sizeAtoms)
2876
+ const baseAtoms = (args.action === "place" || args.action === "replace") && sizeAtoms !== undefined
2877
+ ? BigInt(sizeAtoms)
2403
2878
  : 0n;
2404
- const notional = await estimateBaseNotionalUsd(resolver, markFor, args.marketId, baseAtoms);
2405
- const decision = decideAutonomy(autonomy, args.marketId, notional, nowMs());
2879
+ const notional = resolved?.notionalUsd
2880
+ ?? await estimateBaseNotionalUsd(resolver, markFor, marketId, baseAtoms);
2881
+ const decision = decideAutonomy(autonomy, marketId, notional, nowMs());
2406
2882
  if (!decision.allow) {
2407
- const prepared = await platformClient.orders.prepare(args.marketId, {
2883
+ const prepared = await platformClient.orders.prepare(marketId, {
2408
2884
  operation: challenge,
2409
2885
  });
2410
- return refuse(decision.reason, prepared, decision.reason);
2886
+ return toolResult({
2887
+ executed: false,
2888
+ reason: decision.reason,
2889
+ prepared,
2890
+ ...(resolved === null ? {} : { resolved_intent: resolved.resolution }),
2891
+ }, decision.reason);
2411
2892
  }
2412
2893
  const { sessionPublicKey: _session, ...operation } = challenge;
2413
- const receipt = await platformClient.orders.execute(args.marketId, {
2894
+ const idempotencyKey = args.idempotencyKey ?? clientOrderId ?? `mcp-${nowMs().toString(36)}`;
2895
+ if (args.action === "place" && (friendly || args.deadManTimeoutMs !== undefined)) {
2896
+ if (!persistentOrderGuards) {
2897
+ return toolError("persistent_guard_unavailable", "Autonomous resting orders require a persistent stdio MCP runtime so dead-man heartbeats continue after this call.", false);
2898
+ }
2899
+ const connection = await orderConnections.get(marketId, autonomy);
2900
+ const guarded = await executeGuardedOrder(connection, autonomy, operation, idempotencyKey, args.deadManTimeoutMs ?? 10_000);
2901
+ if (!guarded.ok) {
2902
+ return toolError(guarded.code, guarded.message, true, undefined, guarded.details);
2903
+ }
2904
+ const lifecycle = await observePlacedOrderLifecycle(platformClient, autonomy.ownerWallet, guarded.receipt.order_ids);
2905
+ if (notional !== null)
2906
+ autonomy.dailyBudget.record(notional, nowMs());
2907
+ return toolResult({
2908
+ executed: true,
2909
+ submission_state: guarded.status?.status === "submitted"
2910
+ ? "confirmed"
2911
+ : guarded.status?.status ?? "submitted_unverified",
2912
+ order_state: lifecycle.state,
2913
+ receipt: guarded.receipt,
2914
+ order_status: guarded.status,
2915
+ order_status_error: guarded.statusError,
2916
+ lifecycle,
2917
+ dead_man: guarded.deadMan,
2918
+ notional_usd: notional,
2919
+ ...(resolved === null ? {} : { resolved_intent: resolved.resolution }),
2920
+ }, `Placed guarded ${side} order ${guarded.receipt.order_ids.join(", ")} as ${guarded.receipt.signature}.`);
2921
+ }
2922
+ const receipt = await platformClient.orders.execute(marketId, {
2414
2923
  operation: operation,
2415
2924
  signer: autonomy.signer,
2416
- ...(args.idempotencyKey === undefined ? {} : { idempotencyKey: args.idempotencyKey }),
2925
+ idempotencyKey,
2417
2926
  });
2418
2927
  if (notional !== null)
2419
2928
  autonomy.dailyBudget.record(notional, nowMs());
2420
- return toolResult({ executed: true, receipt, notional_usd: notional }, `Executed ${receipt.action} control ${receipt.order_control_id} as ${receipt.signature}.`);
2929
+ return toolResult({
2930
+ executed: true,
2931
+ receipt,
2932
+ notional_usd: notional,
2933
+ ...(resolved === null ? {} : { resolved_intent: resolved.resolution }),
2934
+ }, `Executed ${receipt.action} control ${receipt.order_control_id} as ${receipt.signature}.`);
2421
2935
  }));
2422
2936
  // ── one-shot TWAP (schedule / cancel) ─────────────────────────────────────
2423
2937
  registerTool("strata_twap_execute", {
@@ -2444,6 +2958,9 @@ function registerAutonomyTools(registerTool, client, platformClient, autonomy, n
2444
2958
  openWorldHint: true,
2445
2959
  },
2446
2960
  }, async (args) => safeTool(async () => {
2961
+ const autonomy = await autonomyProvider();
2962
+ if (!autonomy)
2963
+ return tradingNotConnected();
2447
2964
  let operation;
2448
2965
  if (args.action === "cancel") {
2449
2966
  if (args.twapId === undefined)
@@ -2540,7 +3057,7 @@ async function guardedTool(client, capabilityId, operation) {
2540
3057
  }
2541
3058
  catch (error) {
2542
3059
  if (error instanceof StrataApiError) {
2543
- return toolError(error.code, friendlyApiError(error.code, error.message), error.retryable);
3060
+ return toolError(error.code, friendlyApiError(error.code, error.message), error.retryable, error.retryAfterMs);
2544
3061
  }
2545
3062
  return toolError("request_failed", safeMessage(error), true);
2546
3063
  }
@@ -2551,7 +3068,7 @@ async function safeTool(operation) {
2551
3068
  }
2552
3069
  catch (error) {
2553
3070
  if (error instanceof StrataApiError) {
2554
- return toolError(error.code, friendlyApiError(error.code, error.message), error.retryable);
3071
+ return toolError(error.code, friendlyApiError(error.code, error.message), error.retryable, error.retryAfterMs);
2555
3072
  }
2556
3073
  return toolError("request_failed", safeMessage(error), true);
2557
3074
  }
@@ -2564,7 +3081,7 @@ async function retryReadOnce(operation) {
2564
3081
  catch (error) {
2565
3082
  if (!(error instanceof StrataApiError) || !error.retryable)
2566
3083
  throw error;
2567
- await new Promise((resolve) => setTimeout(resolve, 150));
3084
+ await new Promise((resolve) => setTimeout(resolve, error.retryAfterMs ?? 150));
2568
3085
  return operation();
2569
3086
  }
2570
3087
  }
@@ -2596,8 +3113,16 @@ function toolResult(value, summary) {
2596
3113
  structuredContent: value,
2597
3114
  };
2598
3115
  }
2599
- function toolError(code, message, retryable) {
2600
- const error = { error: { code, message, retryable } };
3116
+ function toolError(code, message, retryable, retryAfterMs, details) {
3117
+ const error = {
3118
+ error: {
3119
+ code,
3120
+ message,
3121
+ retryable,
3122
+ ...(retryAfterMs === undefined ? {} : { retry_after_ms: retryAfterMs }),
3123
+ ...(details === undefined ? {} : { details }),
3124
+ },
3125
+ };
2601
3126
  return {
2602
3127
  isError: true,
2603
3128
  content: [{ type: "text", text: JSON.stringify(error) }],
@@ -17,6 +17,7 @@ export const SIMPLE_TOOL_NAMES = new Set([
17
17
  "strata_market_making_intent_execute",
18
18
  "strata_autonomy",
19
19
  "strata_trade",
20
+ "strata_order_execute",
20
21
  ]);
21
22
  export function parseToolMode(raw) {
22
23
  const value = raw?.trim().toLowerCase() || "simple";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratabook/mcp",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Connect AI agents to Strata markets and Sonar quotes with MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT OR Apache-2.0",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@modelcontextprotocol/sdk": "1.30.0",
48
- "@stratabook/sdk": "0.2.16",
48
+ "@stratabook/sdk": "0.2.18",
49
49
  "zod": "^3.25.76"
50
50
  },
51
51
  "devDependencies": {