@stratabook/mcp 0.2.17 → 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,22 @@
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
+
6
22
  ## 0.2.17
7
23
 
8
24
  - Consume the matching strategy-timed TypeScript SDK and allow an optional
package/README.md CHANGED
@@ -62,9 +62,11 @@ returns to the live Control Center. Run the same command again to replace the
62
62
  old key atomically; the old key is revoked in the same signed transaction.
63
63
 
64
64
  There is no secret to paste into chat, no environment-variable screen, and no
65
- client config to edit after the read-only server is installed. Restart or
66
- refresh the MCP client after the browser confirms connection. Revoke the exact
67
- 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:
68
70
 
69
71
  ```sh
70
72
  npx -y @stratabook/mcp disconnect
@@ -106,8 +108,9 @@ The compact default exposes the tools ordinary users need:
106
108
  - `strata_quote` — accepts `0.1 SOL`, `20 USDC`, or `$20`; token atoms remain optional
107
109
  - `strata_portfolio` and `strata_market_making_status`
108
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
109
112
  - `strata_market_making_prepare` and `strata_market_making_submit_and_wait`
110
- - `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
111
114
 
112
115
  Advanced mode additionally exposes the complete protocol surface, including:
113
116
 
@@ -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)}…, `
@@ -1,6 +1,6 @@
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";
@@ -70,13 +70,23 @@ export async function readTradingConnection(path = tradingCredentialsPath()) {
70
70
  }
71
71
  export async function writeTradingConnection(connection, path = tradingCredentialsPath()) {
72
72
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
73
- await writeFile(path, `${JSON.stringify(connection, null, 2)}\n`, {
74
- encoding: "utf8",
75
- mode: 0o600,
76
- });
77
- // `mode` is honored on creation. chmod also tightens a pre-existing file.
78
- if (process.platform !== "win32")
79
- 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
+ }
80
90
  }
81
91
  export async function removeTradingConnection(path = tradingCredentialsPath()) {
82
92
  try {
@@ -309,6 +319,6 @@ export async function runLocalPairing(options) {
309
319
  process.stdout.write("Waiting for the owner-wallet signature…\n");
310
320
  const ownerWallet = await callback.completion;
311
321
  process.stdout.write(options.action === "connect"
312
- ? `✓ 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`
313
323
  : `✓ Session revoked for ${ownerWallet}. Local trading credentials removed.\n`);
314
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,
@@ -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.17",
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.17",
48
+ "@stratabook/sdk": "0.2.18",
49
49
  "zod": "^3.25.76"
50
50
  },
51
51
  "devDependencies": {