@unhedged/mcp 0.1.3 → 0.2.0

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/README.md CHANGED
@@ -4,7 +4,7 @@ Model Context Protocol server for the [Unhedged](https://unhedged.gg) prediction
4
4
 
5
5
  ## What it does
6
6
 
7
- - Exposes 12 MCP **tools** over the Unhedged v1 REST API: search markets, read balances and portfolio, place bets and orders, cancel orders, request withdrawals, and read a market-maker profile.
7
+ - Exposes MCP **tools** over the Unhedged v1 REST API: search markets, read balances and portfolio, place bets and orders (including batch place/replace), cancel orders, request withdrawals, and read a market-maker profile.
8
8
  - Ships 6 MCP **prompts** ("skills") — opinionated markdown workflows the agent can execute end-to-end (e.g. `place-hedged-position`, `cash-out`, `portfolio-review`).
9
9
  - Auto-generates an `Idempotency-Key` for every state-changing call. Each new call uses a fresh key, so ambiguous failures are not retried automatically. Check the account's transaction history before repeating a mutation; open orders and balances cannot rule out a filled order or completed withdrawal. Ask the user if the outcome remains uncertain.
10
10
  - Declares MCP tool annotations (`readOnlyHint`, `destructiveHint`); bets, orders, cancellations, and withdrawals are marked destructive. Confirmation behavior depends on the client.
@@ -20,6 +20,27 @@ The init wizard asks which client to wire up (Claude Desktop, Cursor, Claude CLI
20
20
 
21
21
  Mint an API key at <https://unhedged.gg/account/api-keys>. See the [API key minting walkthrough](#api-key-minting) below for recommended scope sets.
22
22
 
23
+ ## Upgrading from 0.1.3 to 0.2.0
24
+
25
+ Version 0.2.0 adds centi-share support. MCP order quantities now use **two-decimal strings** in human shares, with a minimum of `"0.01"`. This is a breaking change from 0.1.3's integer quantity inputs.
26
+
27
+ | Tool | Quantity format |
28
+ |---|---|
29
+ | `place_order` | `quantity: "1.00"` |
30
+ | `batch_place_orders` | `quantity: "1.00"` in each order |
31
+ | `replace_order` | `quantity: "1.00"` |
32
+ | `batch_replace_orders` | `quantity: "1.00"` in each replacement |
33
+
34
+ Use `"0.01"` for one hundredth of a share, `"1.00"` for one share, or `"1.25"` for one and a quarter shares. Order responses also return share quantities as decimal strings. REST's support for whole-number numeric inputs does not apply to these MCP tool schemas.
35
+
36
+ To pin the release, configure your MCP client to launch:
37
+
38
+ ```bash
39
+ npx -y @unhedged/mcp@0.2.0
40
+ ```
41
+
42
+ Keep the existing API-key environment setting, then restart the MCP server or client to load the new version and tool schemas. See the [order API reference](https://unhedged.gg/docs/api#place-order) for REST request and response examples.
43
+
23
44
  ## Environment variables
24
45
 
25
46
  | Variable | Required | Description |
@@ -61,7 +82,10 @@ Tool descriptions are available in your MCP client. Quick reference:
61
82
  | `place_bet` | `bet:place` | yes |
62
83
  | `list_orders` | `order:read` | n/a |
63
84
  | `place_order` | `order:place` | yes |
85
+ | `batch_place_orders` | `order:place` | yes |
64
86
  | `cancel_order` | `order:cancel` | yes |
87
+ | `replace_order` | `order:place` + `order:cancel` | yes |
88
+ | `batch_replace_orders` | `order:place` + `order:cancel` | yes |
65
89
  | `request_withdrawal` | `balance:withdraw` | yes |
66
90
  | `get_market_maker_profile` | `portfolio:read` | n/a |
67
91
 
@@ -18,6 +18,14 @@ export class UnhedgedApiError extends Error {
18
18
  this.retryable = args.status >= 500 || args.status === 429 || args.status === 0;
19
19
  }
20
20
  toAgentMessage(stateChanging = false) {
21
+ if (stateChanging && this.status === 400 &&
22
+ this.responseBody != null && typeof this.responseBody === 'object' &&
23
+ 'results' in this.responseBody && Array.isArray(this.responseBody.results)) {
24
+ return (`Unhedged API returned 400: ${this.message}. The batch transaction committed. ` +
25
+ `Successful legs remain committed; failed replacements may have cancelled their source orders. ` +
26
+ `Do not repeat successful legs. Inspect each result before retrying or replacing orders.\n` +
27
+ JSON.stringify({ results: this.responseBody.results }, null, 2));
28
+ }
21
29
  if (this.missingScope) {
22
30
  return (`Unhedged API rejected the call (${this.status}): the API key is missing scope ` +
23
31
  `"${this.missingScope}". Mint a new key including this scope at ` +
@@ -97,6 +97,7 @@ export interface PlaceBetResponse {
97
97
  }
98
98
  export interface MEOrder {
99
99
  id?: string | number;
100
+ client_order_id?: string | null;
100
101
  marketId: string;
101
102
  outcome: string;
102
103
  side: 'bid' | 'ask';
@@ -105,6 +106,10 @@ export interface MEOrder {
105
106
  triggerPrice?: string;
106
107
  quantity?: number;
107
108
  quoteQuantity?: string;
109
+ /** Filled contracts; remaining size is `quantity - cum_quantity`. */
110
+ cum_quantity?: number;
111
+ /** Filled quote notional string; remaining quote is `quote_quantity - cum_quote_quantity`. */
112
+ cum_quote_quantity?: string;
108
113
  status?: string;
109
114
  [key: string]: unknown;
110
115
  }
@@ -117,9 +122,21 @@ export interface ListOrdersResponse {
117
122
  export interface PlaceOrderResponse {
118
123
  order: MEOrder;
119
124
  }
125
+ export interface BatchPlaceOrderResponse {
126
+ results: Array<{
127
+ order?: MEOrder;
128
+ err?: unknown;
129
+ }>;
130
+ }
120
131
  export interface CancelOrderResponse {
121
132
  result: unknown;
122
133
  }
134
+ export interface ReplaceOrderResponse {
135
+ result: unknown;
136
+ }
137
+ export interface BatchReplaceOrderResponse {
138
+ results: unknown[];
139
+ }
123
140
  export interface EthereumWithdrawQuoteResponse {
124
141
  quote: {
125
142
  quoteId: string;
@@ -1,7 +1,9 @@
1
+ import BigNumber from 'bignumber.js';
1
2
  /**
2
3
  * In-process session spend cap. Tracks how much an agent has committed across
3
- * `place_bet` and `place_order` (bid side) calls within a single server
4
- * lifetime, and refuses further spend once the configured cap is reached.
4
+ * `place_bet`, `place_order` / `batch_place_orders` (bid side), and incremental
5
+ * bid notional from `replace_order` / `batch_replace_orders` within a single
6
+ * server lifetime, and refuses further spend once the configured cap is reached.
5
7
  *
6
8
  * Independent of (additive to) any rate or balance limits the backend enforces.
7
9
  * Resets when the process restarts.
@@ -16,15 +18,15 @@ export declare class SpendCapTracker {
16
18
  effectiveCap(): number | null;
17
19
  /**
18
20
  * Throws SpendCapExceededError if `amount` would push cumulative spend over
19
- * the cap. If the cap is null, this is a no-op. Calls `commit` on success.
21
+ * the cap. If the cap is null, this is a no-op.
20
22
  */
21
- reserve(amount: number, toolName: string): void;
23
+ reserve(amount: BigNumber.Value, toolName: string): void;
22
24
  /**
23
25
  * Return a previously reserved amount to the cap, e.g. when the backend
24
26
  * definitively rejected the placement (4xx) so nothing was actually spent.
25
27
  * Ambiguous failures (network errors, 5xx) should NOT be released.
26
28
  */
27
- release(amount: number): void;
29
+ release(amount: BigNumber.Value): void;
28
30
  }
29
31
  export declare class SpendCapExceededError extends Error {
30
32
  readonly spent: number;
@@ -1,20 +1,22 @@
1
+ import BigNumber from 'bignumber.js';
1
2
  /**
2
3
  * In-process session spend cap. Tracks how much an agent has committed across
3
- * `place_bet` and `place_order` (bid side) calls within a single server
4
- * lifetime, and refuses further spend once the configured cap is reached.
4
+ * `place_bet`, `place_order` / `batch_place_orders` (bid side), and incremental
5
+ * bid notional from `replace_order` / `batch_replace_orders` within a single
6
+ * server lifetime, and refuses further spend once the configured cap is reached.
5
7
  *
6
8
  * Independent of (additive to) any rate or balance limits the backend enforces.
7
9
  * Resets when the process restarts.
8
10
  */
9
11
  export class SpendCapTracker {
10
12
  cap;
11
- spent = 0;
13
+ spent = new BigNumber(0);
12
14
  constructor(cap) {
13
15
  this.cap = cap;
14
16
  }
15
17
  /** How much has been committed so far (USD). */
16
18
  totalSpent() {
17
- return this.spent;
19
+ return this.spent.toNumber();
18
20
  }
19
21
  /** Effective cap, or `null` if unset. */
20
22
  effectiveCap() {
@@ -22,22 +24,24 @@ export class SpendCapTracker {
22
24
  }
23
25
  /**
24
26
  * Throws SpendCapExceededError if `amount` would push cumulative spend over
25
- * the cap. If the cap is null, this is a no-op. Calls `commit` on success.
27
+ * the cap. If the cap is null, this is a no-op.
26
28
  */
27
29
  reserve(amount, toolName) {
28
30
  if (this.cap === null)
29
31
  return;
30
- if (!Number.isFinite(amount) || amount < 0) {
32
+ const value = new BigNumber(amount);
33
+ if (!value.isFinite() || value.lt(0)) {
31
34
  throw new SpendCapExceededError(`Tool ${toolName} attempted to reserve a negative or non-finite amount (${amount}). ` +
32
- `Refusing as a safety measure.`, this.spent, this.cap);
35
+ `Refusing as a safety measure.`, this.totalSpent(), this.cap);
33
36
  }
34
- if (this.spent + amount > this.cap) {
35
- throw new SpendCapExceededError(`Refusing ${toolName}: this call would commit ${amount.toFixed(2)} USD, bringing the ` +
36
- `session total to ${(this.spent + amount).toFixed(2)} USD and exceeding the configured ` +
37
+ const next = this.spent.plus(value);
38
+ if (next.gt(this.cap)) {
39
+ throw new SpendCapExceededError(`Refusing ${toolName}: this call would commit ${value.toFixed(2)} USD, bringing the ` +
40
+ `session total to ${next.toFixed(2)} USD and exceeding the configured ` +
37
41
  `cap of ${this.cap.toFixed(2)} USD (UNHEDGED_MAX_SPEND_PER_SESSION). ` +
38
- `Restart the MCP server or raise the cap to continue.`, this.spent, this.cap);
42
+ `Restart the MCP server or raise the cap to continue.`, this.totalSpent(), this.cap);
39
43
  }
40
- this.spent += amount;
44
+ this.spent = next;
41
45
  }
42
46
  /**
43
47
  * Return a previously reserved amount to the cap, e.g. when the backend
@@ -47,9 +51,10 @@ export class SpendCapTracker {
47
51
  release(amount) {
48
52
  if (this.cap === null)
49
53
  return;
50
- if (!Number.isFinite(amount) || amount <= 0)
54
+ const value = new BigNumber(amount);
55
+ if (!value.isFinite() || value.lte(0))
51
56
  return;
52
- this.spent = Math.max(0, this.spent - amount);
57
+ this.spent = BigNumber.maximum(0, this.spent.minus(value));
53
58
  }
54
59
  }
55
60
  export class SpendCapExceededError extends Error {
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import BigNumber from 'bignumber.js';
2
3
  import { defineTool, READ_ONLY } from './types.js';
3
4
  import { UnhedgedApiError } from '../client/errors.js';
4
5
  import { SpendCapExceededError } from '../safety/spend-cap.js';
@@ -13,7 +14,7 @@ Filters:
13
14
  - **includeTotal**: include an exact account-wide total.
14
15
  - **includeMarketDetails**: include compact market metadata for account-wide rows.
15
16
 
16
- Returns \`{ orders, nextCursor }\` and, when requested, \`total\` / \`markets\`. Follow \`nextCursor\` until it is null when a complete account-wide inventory is required. Each order has \`id\`, \`marketId\`, \`outcome\`, \`side\` (\`bid\` or \`ask\`), \`type\` (\`Limit | Market | StopLoss | TakeProfit\`), \`price\`, \`quantity\` or \`quoteQuantity\`, and status.
17
+ Returns \`{ orders, nextCursor }\` and, when requested, \`total\` / \`markets\`. Follow \`nextCursor\` until it is null when a complete account-wide inventory is required. Each order has \`id\`, optional \`client_order_id\`, \`market_id\`, \`outcome\`, \`side\` (\`bid\` or \`ask\`), \`type\` (\`Limit | Market | StopLoss | TakeProfit\`), \`price\`, \`quantity\` or \`quote_quantity\`, and status. Pending conditional orders (StopLoss/TakeProfit) are included while open.
17
18
 
18
19
  Only works on CLOB markets — parimutuel markets won't have any orders to return.
19
20
 
@@ -43,15 +44,20 @@ Required parameters:
43
44
  - **outcome**: outcome label, e.g. \`"YES"\`, \`"NO"\`, \`"$95k - $100k"\`. Match exactly the labels from \`get_market\`.
44
45
  - **side**: \`"bid"\` to buy a contract on this outcome, \`"ask"\` to sell.
45
46
  - **type**: \`"Limit"\`, \`"Market"\`, \`"StopLoss"\`, or \`"TakeProfit"\`.
46
- - **One of** \`quantity\` (positive integer contract count, e.g. \`5\`) **or** \`quoteQuantity\` (decimal USD notional string with exactly 2 decimals). Not both.
47
+ - **One of** \`quantity\` (contracts as a 2-decimal string, min \`"0.01"\`, e.g. \`"5.00"\`) **or** \`quoteQuantity\` (decimal USD notional string with exactly 2 decimals). Not both.
47
48
 
48
49
  Conditional parameters:
49
50
  - **price**: required for \`Limit\`. Decimal string with exactly 2 decimals, strictly between 0 and 1 (probability).
50
51
  - **triggerPrice**: required for \`StopLoss\` and \`TakeProfit\`. Same bounds as price.
51
52
 
52
- Returns \`{ order: ... }\` with the engine-assigned ID, status, and any immediate fills.
53
+ Optional:
54
+ - **clientOrderId**: your own id for this open order (1–64 chars, \`[A-Za-z0-9._:-]\`, case preserved). Unique among **open** orders for this balance in this market (all outcomes and API keys share that namespace). Echoed on the order as \`client_order_id\`. After the order closes the alias may be reused — that does **not** make a timed-out placement safe to retry with the same id.
53
55
 
54
- The MCP server automatically adds an \`Idempotency-Key\`.
56
+ Returns \`{ order: ... }\` with the engine-assigned ID, optional \`client_order_id\`, status, and any immediate fills.
57
+
58
+ The MCP server automatically adds a fresh \`Idempotency-Key\` on each call. It
59
+ does not retry automatically, so calling this tool again creates a new request
60
+ rather than replaying the first.
55
61
 
56
62
  Requires scope: \`order:place\`.
57
63
 
@@ -68,7 +74,8 @@ Buy 5 contracts of YES at probability 0.55:
68
74
  "side": "bid",
69
75
  "type": "Limit",
70
76
  "price": "0.55",
71
- "quantity": 5
77
+ "quantity": "5.00",
78
+ "clientOrderId": "bot-42"
72
79
  }
73
80
  \`\`\`
74
81
 
@@ -88,22 +95,36 @@ Cancel one, several, or all of the user's open orders on a single CLOB market.
88
95
 
89
96
  Required:
90
97
  - **marketId**: market to cancel on.
91
- - **orderIds**: array of order IDs (as strings) to cancel. **Pass an empty array \`[]\` to cancel all of the user's open orders on the market.**
98
+ - **orderIds**: array of numeric order IDs (as strings). May be \`[]\` when cancelling only by \`clientOrderIds\`, or when cancelling all.
99
+
100
+ Optional:
101
+ - **clientOrderIds**: array of client order IDs (same charset/length as placement). Targets are the **union** of \`orderIds\` and resolved \`clientOrderIds\`. Unresolved client IDs are soft failures (continue cancelling the rest; first failure in \`err\`). Case is preserved.
102
+
103
+ **Cancel-all** (every open order for this balance on the market) runs only when **both** \`orderIds\` and \`clientOrderIds\` are empty (omit \`clientOrderIds\` or pass \`[]\`). A nonempty \`clientOrderIds\` list with \`orderIds: []\` is a **targeted** cancel, not cancel-all.
104
+
105
+ Numeric IDs and client IDs are best-effort (\`dropped\` plus \`err\` on partial success). An alias can be reused after an order closes, so a later cancel by the same string may hit a **different** open order — do not treat repeat cancel as a durable receipt for the original intent.
92
106
 
93
- Returns \`{ result }\` with engine-side per-order outcomes.
107
+ Returns \`{ result }\` with engine-side outcomes (typically \`dropped\` order ids).
94
108
 
95
- The market must still be ACTIVE and before the trading cutoff (\`scheduledResolutionTime\` or \`endTime\`); cancelling on a closed market is rejected.
109
+ If the market is not ACTIVE or the trading cutoff has passed, REST returns HTTP
110
+ 200 with an empty \`dropped\` list without calling the engine.
96
111
 
97
- The MCP server adds an \`Idempotency-Key\` automatically.
112
+ The MCP server adds a fresh \`Idempotency-Key\` on each call. The API response
113
+ cache excludes \`DELETE\`, so cancel requests are not replay-protected.
98
114
 
99
115
  Requires scope: \`order:cancel\`.
100
116
 
101
117
  ### Examples
102
- Cancel two specific orders:
118
+ Cancel two specific orders by numeric id:
103
119
  \`\`\`json
104
120
  { "marketId": "clxabc123...", "orderIds": ["1042", "1051"] }
105
121
  \`\`\`
106
122
 
123
+ Cancel by client id only:
124
+ \`\`\`json
125
+ { "marketId": "clxabc123...", "orderIds": [], "clientOrderIds": ["bot-42"] }
126
+ \`\`\`
127
+
107
128
  Cancel **all** open orders on this market:
108
129
  \`\`\`json
109
130
  { "marketId": "clxabc123...", "orderIds": [] }
@@ -131,6 +152,13 @@ const positiveDecimalStringSchema = z
131
152
  const n = Number(value);
132
153
  return Number.isFinite(n) && n > 0;
133
154
  });
155
+ /** Matches REST `clientOrderId` / `clientOrderIds`: 1–64 of [A-Za-z0-9._:-], case preserved. */
156
+ const clientOrderIdSchema = z
157
+ .string()
158
+ .min(1)
159
+ .max(64)
160
+ .regex(/^[A-Za-z0-9._:-]+$/)
161
+ .describe('Client order id (1–64 chars, letters/digits/._:-). Unique among open orders per balance+market.');
134
162
  const placeSchema = {
135
163
  marketId: z.string().min(1),
136
164
  outcome: z.string().min(1).describe('Outcome label, e.g. "YES" or "$95k - $100k".'),
@@ -142,21 +170,23 @@ const placeSchema = {
142
170
  triggerPrice: probabilityStringSchema
143
171
  .optional()
144
172
  .describe('Trigger probability decimal string between 0 and 1 with exactly 2 decimals. Required for StopLoss/TakeProfit.'),
145
- quantity: z
146
- .number()
147
- .int()
148
- .positive()
173
+ quantity: positiveDecimalStringSchema
149
174
  .optional()
150
- .describe('Number of contracts as a positive integer.'),
175
+ .describe('Number of contracts as a 2-decimal string (min "0.01").'),
151
176
  quoteQuantity: positiveDecimalStringSchema
152
177
  .optional()
153
178
  .describe('USD notional string with exactly 2 decimals. Use INSTEAD of quantity.'),
179
+ clientOrderId: clientOrderIdSchema.optional(),
154
180
  };
155
181
  const cancelSchema = {
156
182
  marketId: z.string().min(1),
157
183
  orderIds: z
158
184
  .array(z.string())
159
- .describe('Order IDs to cancel. Pass [] to cancel ALL of the user\'s orders on this market.'),
185
+ .describe('Numeric order IDs to cancel. Pass [] with clientOrderIds for client-only cancel, or [] alone (and empty/omitted clientOrderIds) for cancel-all.'),
186
+ clientOrderIds: z
187
+ .array(clientOrderIdSchema)
188
+ .optional()
189
+ .describe('Optional client order IDs to cancel (union with orderIds). Unknown ids reject the whole request. Both lists empty = cancel-all.'),
160
190
  };
161
191
  /**
162
192
  * Best-effort client-side notional estimate for the spend cap.
@@ -164,17 +194,175 @@ const cancelSchema = {
164
194
  */
165
195
  function estimateBidNotional(args) {
166
196
  if (args.side !== 'bid')
167
- return 0;
197
+ return new BigNumber(0);
168
198
  if (typeof args.quoteQuantity === 'string')
169
- return Number(args.quoteQuantity);
170
- if (args.type === 'Limit' && typeof args.price === 'string' && typeof args.quantity === 'number') {
171
- const price = Number(args.price);
172
- if (!Number.isFinite(price))
199
+ return finiteDecimal(args.quoteQuantity) ?? null;
200
+ if (args.type === 'Limit' && typeof args.price === 'string' && args.quantity != null) {
201
+ const price = new BigNumber(args.price);
202
+ const qty = new BigNumber(args.quantity);
203
+ if (!price.isFinite() || !qty.isFinite())
173
204
  return null;
174
- return price * args.quantity;
205
+ return price.times(qty);
175
206
  }
176
207
  return null;
177
208
  }
209
+ function isRecord(value) {
210
+ return value != null && typeof value === 'object' && !Array.isArray(value);
211
+ }
212
+ function isInternalCancelStatus(status) {
213
+ if (status === 'InternalCancel')
214
+ return true;
215
+ return isRecord(status) && Object.prototype.hasOwnProperty.call(status, 'InternalCancel');
216
+ }
217
+ function positiveNumericField(record, field) {
218
+ const value = record[field];
219
+ if (typeof value === 'number')
220
+ return Number.isFinite(value) && value > 0;
221
+ if (typeof value === 'string') {
222
+ const parsed = Number(value);
223
+ return Number.isFinite(parsed) && parsed > 0;
224
+ }
225
+ return false;
226
+ }
227
+ /** Match backend `hasExecutedFill`: either base or quote cumulative fill counts. */
228
+ function orderHasExecutedFill(order) {
229
+ return (positiveNumericField(order, 'cum_quantity') ||
230
+ positiveNumericField(order, 'cumQuantity') ||
231
+ positiveNumericField(order, 'cum_quote_quantity') ||
232
+ positiveNumericField(order, 'cumQuoteQuantity'));
233
+ }
234
+ /** Per-leg outcomes from a committed batch returned with HTTP 400. */
235
+ function resultsFromApiError(err) {
236
+ if (!isRecord(err.responseBody) || !Array.isArray(err.responseBody.results))
237
+ return null;
238
+ return err.responseBody.results;
239
+ }
240
+ /** True when a batch-place leg committed buy exposure we should keep reserved. */
241
+ function batchPlaceLegConsumedSpend(result) {
242
+ if (result == null || result.order == null)
243
+ return false;
244
+ if (!isRecord(result.order))
245
+ return false;
246
+ // Soft InternalCancel with no fills: nothing locked/spent — release like `{ err }`.
247
+ if (isInternalCancelStatus(result.order.status)) {
248
+ return orderHasExecutedFill(result.order);
249
+ }
250
+ return true;
251
+ }
252
+ /**
253
+ * True when a replace outcome kept incremental buy exposure.
254
+ * Used for single `replace_order` results and batch-replace legs.
255
+ * Releases `{ err }`, `unchanged`, and zero-fill InternalCancel replacements.
256
+ */
257
+ function batchReplaceLegConsumedSpend(result) {
258
+ if (!isRecord(result))
259
+ return false;
260
+ if ('err' in result)
261
+ return false;
262
+ if (result.status === 'unchanged')
263
+ return false;
264
+ const replacement = result.replacementOrder ?? result.replacement_order;
265
+ if (isRecord(replacement) && isInternalCancelStatus(replacement.status)) {
266
+ return orderHasExecutedFill(replacement);
267
+ }
268
+ return true;
269
+ }
270
+ function orderIdOf(order) {
271
+ if (order.id == null)
272
+ return undefined;
273
+ return String(order.id);
274
+ }
275
+ function clientOrderIdOf(order) {
276
+ const value = order.client_order_id ?? order.clientOrderId;
277
+ return typeof value === 'string' ? value : undefined;
278
+ }
279
+ function sideOf(order) {
280
+ return order.side === 'bid' || order.side === 'ask' ? order.side : null;
281
+ }
282
+ function quoteQuantityOf(order) {
283
+ const value = order.quoteQuantity ?? order.quote_quantity;
284
+ return typeof value === 'string' ? value : undefined;
285
+ }
286
+ function finiteDecimal(value) {
287
+ if (typeof value !== 'number' && typeof value !== 'string')
288
+ return undefined;
289
+ const parsed = new BigNumber(value);
290
+ return parsed.isFinite() ? parsed : undefined;
291
+ }
292
+ function orderTypeOf(order) {
293
+ const value = order.type;
294
+ if (value === 'Limit' || value === 'Market' || value === 'StopLoss' || value === 'TakeProfit') {
295
+ return value;
296
+ }
297
+ return 'Limit';
298
+ }
299
+ function findOpenOrder(orders, identity) {
300
+ if (identity.orderId != null) {
301
+ return orders.find((order) => orderIdOf(order) === identity.orderId);
302
+ }
303
+ if (identity.clientOrderId != null) {
304
+ return orders.find((order) => clientOrderIdOf(order) === identity.clientOrderId);
305
+ }
306
+ return undefined;
307
+ }
308
+ /**
309
+ * Estimated buy notional still locked by an open source bid (remaining size).
310
+ * Partially filled quantity orders use `quantity - cum_quantity`; quote-budget
311
+ * orders use leftover `quote_quantity - cum_quote_quantity`.
312
+ */
313
+ function estimateSourceBidLockNotional(source) {
314
+ const quoteQuantity = quoteQuantityOf(source);
315
+ if (typeof quoteQuantity === 'string') {
316
+ const remainingQuote = new BigNumber(quoteQuantity).minus(finiteDecimal(source.cum_quote_quantity ?? source.cumQuoteQuantity) ?? 0);
317
+ if (!remainingQuote.isFinite())
318
+ return null;
319
+ return BigNumber.maximum(0, remainingQuote);
320
+ }
321
+ const quantity = finiteDecimal(source.quantity);
322
+ if (quantity === undefined)
323
+ return null;
324
+ const remainingQuantity = BigNumber.maximum(0, quantity.minus(finiteDecimal(source.cum_quantity ?? source.cumQuantity) ?? 0));
325
+ return estimateBidNotional({
326
+ side: 'bid',
327
+ type: orderTypeOf(source),
328
+ price: typeof source.price === 'string' ? source.price : undefined,
329
+ quantity: remainingQuantity,
330
+ });
331
+ }
332
+ /**
333
+ * Incremental buy notional a replace would add beyond the source order's
334
+ * current remaining estimated lock. Asks are 0. Returns `null` when
335
+ * side/exposure can't be determined from the open-order snapshot.
336
+ */
337
+ function estimateReplacementSpendDelta(source, replacement) {
338
+ if (source == null)
339
+ return null;
340
+ const side = sideOf(source);
341
+ if (side == null)
342
+ return null;
343
+ if (side === 'ask')
344
+ return new BigNumber(0);
345
+ const oldNotional = estimateSourceBidLockNotional(source);
346
+ if (oldNotional === null)
347
+ return null;
348
+ const newNotional = estimateBidNotional({
349
+ side: 'bid',
350
+ type: 'Limit',
351
+ price: replacement.price,
352
+ quantity: replacement.quantity,
353
+ });
354
+ if (newNotional === null)
355
+ return null;
356
+ return BigNumber.maximum(0, newNotional.minus(oldNotional));
357
+ }
358
+ async function loadOpenOrdersForSpendCap(ctx, marketId) {
359
+ const response = await ctx.client.request({
360
+ method: 'GET',
361
+ path: '/api/v1/orders',
362
+ query: { marketId, limit: 500 },
363
+ });
364
+ return Array.isArray(response.orders) ? response.orders : [];
365
+ }
178
366
  export const orderTools = [
179
367
  defineTool({
180
368
  name: 'list_orders',
@@ -211,7 +399,7 @@ export const orderTools = [
211
399
  if ((args.type === 'StopLoss' || args.type === 'TakeProfit') && args.triggerPrice === undefined) {
212
400
  throw new Error(`triggerPrice is required for ${args.type} orders.`);
213
401
  }
214
- let reserved = 0;
402
+ let reserved = new BigNumber(0);
215
403
  if (ctx.spendCap.effectiveCap() !== null) {
216
404
  const notional = estimateBidNotional(args);
217
405
  if (notional === null) {
@@ -222,16 +410,32 @@ export const orderTools = [
222
410
  reserved = notional;
223
411
  }
224
412
  try {
413
+ const body = {
414
+ marketId: args.marketId,
415
+ outcome: args.outcome,
416
+ side: args.side,
417
+ type: args.type,
418
+ };
419
+ if (args.price !== undefined)
420
+ body.price = args.price;
421
+ if (args.triggerPrice !== undefined)
422
+ body.triggerPrice = args.triggerPrice;
423
+ if (args.quantity !== undefined)
424
+ body.quantity = args.quantity;
425
+ if (args.quoteQuantity !== undefined)
426
+ body.quoteQuantity = args.quoteQuantity;
427
+ if (args.clientOrderId !== undefined)
428
+ body.clientOrderId = args.clientOrderId;
225
429
  return await ctx.client.request({
226
430
  method: 'POST',
227
431
  path: '/api/v1/orders',
228
- body: args,
432
+ body,
229
433
  });
230
434
  }
231
435
  catch (err) {
232
436
  // Definitive server-side rejection (4xx) — nothing was spent, so give
233
437
  // the reservation back. Ambiguous outcomes keep it.
234
- if (reserved > 0 && err instanceof UnhedgedApiError && err.status >= 400 && err.status < 500) {
438
+ if (reserved.gt(0) && err instanceof UnhedgedApiError && err.status >= 400 && err.status < 500) {
235
439
  ctx.spendCap.release(reserved);
236
440
  }
237
441
  throw err;
@@ -243,14 +447,324 @@ export const orderTools = [
243
447
  title: 'Cancel one or more open orders',
244
448
  description: CANCEL_DESCRIPTION,
245
449
  inputSchema: cancelSchema,
246
- // Destructive: an empty orderIds array cancels EVERY open order on the market.
247
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
450
+ // Destructive: both target lists empty cancels EVERY open order on the market.
451
+ // Not durable-idempotent for client aliases: after an order closes the same string
452
+ // can name a later open order.
453
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
248
454
  handler: async (args, ctx) => {
455
+ const body = {
456
+ marketId: args.marketId,
457
+ orderIds: args.orderIds,
458
+ };
459
+ if (args.clientOrderIds !== undefined) {
460
+ body.clientOrderIds = args.clientOrderIds;
461
+ }
249
462
  return ctx.client.request({
250
463
  method: 'DELETE',
251
464
  path: '/api/v1/orders',
252
- body: args,
465
+ body,
253
466
  });
254
467
  },
255
468
  }),
469
+ defineTool({
470
+ name: 'batch_place_orders',
471
+ title: 'Batch-place order-book orders',
472
+ description: `
473
+ Place 1–32 orders on one CLOB market in a single request (\`POST /api/v1/orders/batch\`).
474
+
475
+ Required: **marketId**, **orders** (array of legs). Each leg matches \`place_order\` fields except marketId. **Market legs are not allowed**; use \`place_order\` for Market. Legs may fill. Self-trade follows the market setting.
476
+
477
+ The engine transaction commits even if a leg fails. HTTP **400** with \`{ results }\` when any leg failed (insufficient balance, invalid leg); HTTP **201** when every leg succeeded. Invalid legs are rejected by HTTP before the engine runs.
478
+
479
+ If a **session spend cap** is configured, estimated bid notional across all legs is reserved before the request (same rules as \`place_order\`). Ask legs do not count. HTTP 400 with results releases only failed legs; successful legs keep their reservation. A definitive HTTP 4xx without results releases the whole reservation.
480
+
481
+ Requires scope: \`order:place\`. Counts as one write against rate limits.
482
+ `.trim(),
483
+ inputSchema: {
484
+ marketId: z.string().min(1),
485
+ orders: z
486
+ .array(z.object({
487
+ outcome: placeSchema.outcome,
488
+ side: placeSchema.side,
489
+ type: z.enum(['Limit', 'StopLoss', 'TakeProfit']),
490
+ price: placeSchema.price,
491
+ triggerPrice: placeSchema.triggerPrice,
492
+ quantity: placeSchema.quantity,
493
+ quoteQuantity: placeSchema.quoteQuantity,
494
+ clientOrderId: placeSchema.clientOrderId,
495
+ }))
496
+ .min(1)
497
+ .max(32),
498
+ },
499
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
500
+ handler: async (args, ctx) => {
501
+ for (const [i, leg] of args.orders.entries()) {
502
+ if ((leg.quantity === undefined) === (leg.quoteQuantity === undefined)) {
503
+ throw new Error(`orders[${i}]: provide exactly one of quantity or quoteQuantity.`);
504
+ }
505
+ if (leg.type === 'Limit' && leg.price === undefined) {
506
+ throw new Error(`orders[${i}]: price is required for Limit legs.`);
507
+ }
508
+ if ((leg.type === 'StopLoss' || leg.type === 'TakeProfit') &&
509
+ leg.triggerPrice === undefined) {
510
+ throw new Error(`orders[${i}]: triggerPrice is required for ${leg.type} legs.`);
511
+ }
512
+ }
513
+ // Parallel to args.orders: per-leg estimated bid notional (0 for asks).
514
+ // Used to release only failed legs after a committed partial batch.
515
+ let legNotionals = null;
516
+ let reserved = new BigNumber(0);
517
+ if (ctx.spendCap.effectiveCap() !== null) {
518
+ legNotionals = [];
519
+ for (const [i, leg] of args.orders.entries()) {
520
+ const notional = estimateBidNotional(leg);
521
+ if (notional === null) {
522
+ throw new SpendCapExceededError(`Refusing batch_place_orders: a session spend cap is configured but orders[${i}] notional can't be estimated client-side ` +
523
+ `(${leg.type} ${leg.side} without quoteQuantity). Set quoteQuantity, or use a Limit leg with price+quantity.`, ctx.spendCap.totalSpent(), ctx.spendCap.effectiveCap());
524
+ }
525
+ legNotionals.push(notional);
526
+ }
527
+ const total = legNotionals.reduce((sum, n) => sum.plus(n), new BigNumber(0));
528
+ if (total.gt(0)) {
529
+ ctx.spendCap.reserve(total, 'batch_place_orders');
530
+ reserved = total;
531
+ }
532
+ }
533
+ try {
534
+ const response = await ctx.client.request({
535
+ method: 'POST',
536
+ path: '/api/v1/orders/batch',
537
+ body: {
538
+ marketId: args.marketId,
539
+ orders: args.orders,
540
+ },
541
+ });
542
+ // Also support older API versions that return partial results with HTTP 201.
543
+ if (legNotionals !== null && Array.isArray(response.results)) {
544
+ let toRelease = new BigNumber(0);
545
+ const n = Math.min(legNotionals.length, response.results.length);
546
+ for (let i = 0; i < n; i++) {
547
+ if (!batchPlaceLegConsumedSpend(response.results[i])) {
548
+ toRelease = toRelease.plus(legNotionals[i]);
549
+ }
550
+ }
551
+ if (toRelease.gt(0))
552
+ ctx.spendCap.release(toRelease);
553
+ }
554
+ return response;
555
+ }
556
+ catch (err) {
557
+ if (reserved.gt(0) && err instanceof UnhedgedApiError && err.status >= 400 && err.status < 500) {
558
+ const results = resultsFromApiError(err);
559
+ if (legNotionals !== null && results) {
560
+ let toRelease = new BigNumber(0);
561
+ const n = Math.min(legNotionals.length, results.length);
562
+ for (let i = 0; i < n; i++) {
563
+ if (!batchPlaceLegConsumedSpend(results[i])) {
564
+ toRelease = toRelease.plus(legNotionals[i]);
565
+ }
566
+ }
567
+ if (toRelease.gt(0))
568
+ ctx.spendCap.release(toRelease);
569
+ }
570
+ else {
571
+ ctx.spendCap.release(reserved);
572
+ }
573
+ }
574
+ throw err;
575
+ }
576
+ },
577
+ }),
578
+ defineTool({
579
+ name: 'replace_order',
580
+ title: 'Replace an open Limit order',
581
+ description: `
582
+ Atomically cancel one open Limit order and place a replacement (\`POST /api/v1/orders/replace\`).
583
+
584
+ Required: **marketId**, **price**, **quantity**, and exactly one of **orderId** / **clientOrderId**.
585
+ Optional: **replacementClientOrderId**.
586
+
587
+ If a **session spend cap** is configured, the tool loads the source open order first. Ask replacements do not count. Bid replacements reserve only the **incremental** estimated notional (\`max(0, new − remaining old)\`), where the old lock uses remaining size after \`cum_quantity\` / \`cum_quote_quantity\`. If the source side or exposure cannot be determined (missing from \`list_orders\`, unknown side, or unsized), the call is refused before submit. A definitive HTTP 4xx releases the reservation. On success, the reservation is also released for \`unchanged\` and zero-fill \`InternalCancel\` outcomes (nothing incremental was locked).
588
+
589
+ Requires scopes: \`order:place\` and \`order:cancel\` (plus \`order:read\` when a spend cap is set, to resolve the source).
590
+ `.trim(),
591
+ inputSchema: {
592
+ marketId: z.string().min(1),
593
+ orderId: z.string().regex(/^[0-9]+$/).optional(),
594
+ clientOrderId: clientOrderIdSchema.optional(),
595
+ replacementClientOrderId: clientOrderIdSchema.optional(),
596
+ price: probabilityStringSchema,
597
+ quantity: positiveDecimalStringSchema,
598
+ },
599
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
600
+ handler: async (args, ctx) => {
601
+ const hasOrderId = args.orderId != null;
602
+ const hasClientOrderId = args.clientOrderId != null;
603
+ if (hasOrderId === hasClientOrderId) {
604
+ throw new Error('Specify exactly one of orderId or clientOrderId.');
605
+ }
606
+ let reserved = new BigNumber(0);
607
+ if (ctx.spendCap.effectiveCap() !== null) {
608
+ const openOrders = await loadOpenOrdersForSpendCap(ctx, args.marketId);
609
+ const source = findOpenOrder(openOrders, {
610
+ orderId: args.orderId,
611
+ clientOrderId: args.clientOrderId,
612
+ });
613
+ const delta = estimateReplacementSpendDelta(source, {
614
+ price: args.price,
615
+ quantity: args.quantity,
616
+ });
617
+ if (delta === null) {
618
+ const target = args.orderId != null ? `orderId=${args.orderId}` : `clientOrderId=${args.clientOrderId}`;
619
+ throw new SpendCapExceededError(`Refusing replace_order: a session spend cap is configured but the source side/exposure for ${target} ` +
620
+ `can't be determined from open orders on this market. Ensure the order is still open and the API key ` +
621
+ `has order:read, then retry.`, ctx.spendCap.totalSpent(), ctx.spendCap.effectiveCap());
622
+ }
623
+ if (delta.gt(0)) {
624
+ ctx.spendCap.reserve(delta, 'replace_order');
625
+ reserved = delta;
626
+ }
627
+ }
628
+ const body = {
629
+ marketId: args.marketId,
630
+ price: args.price,
631
+ quantity: args.quantity,
632
+ };
633
+ if (args.orderId !== undefined)
634
+ body.orderId = args.orderId;
635
+ if (args.clientOrderId !== undefined)
636
+ body.clientOrderId = args.clientOrderId;
637
+ if (args.replacementClientOrderId !== undefined) {
638
+ body.replacementClientOrderId = args.replacementClientOrderId;
639
+ }
640
+ try {
641
+ const response = await ctx.client.request({
642
+ method: 'POST',
643
+ path: '/api/v1/orders/replace',
644
+ body,
645
+ });
646
+ // Same no-spend outcomes as a batch-replace leg (`unchanged`, zero-fill IC).
647
+ if (reserved.gt(0) && !batchReplaceLegConsumedSpend(response.result)) {
648
+ ctx.spendCap.release(reserved);
649
+ }
650
+ return response;
651
+ }
652
+ catch (err) {
653
+ if (reserved.gt(0) && err instanceof UnhedgedApiError && err.status >= 400 && err.status < 500) {
654
+ ctx.spendCap.release(reserved);
655
+ }
656
+ throw err;
657
+ }
658
+ },
659
+ }),
660
+ defineTool({
661
+ name: 'batch_replace_orders',
662
+ title: 'Batch-replace open Limit orders',
663
+ description: `
664
+ Replace 1–32 open Limit orders on one market (\`POST /api/v1/orders/replace/batch\`).
665
+
666
+ The engine transaction commits even if a leg fails. A replacement may fill. Self-trade follows the market setting: disabled, it will not trade against the user's own orders. HTTP **400** with \`{ results }\` when any leg failed.
667
+
668
+ If a **session spend cap** is configured, open orders on the market are loaded first. Each bid leg reserves incremental estimated notional (\`max(0, new − remaining old)\`, using remaining size after fills); asks are 0. Bids spanning different outcomes reserve full new notional because another replacement may fill a source before its leg executes. Any leg whose source side/exposure cannot be determined refuses the whole batch before submit. HTTP 400 with results releases failed or unchanged legs; successful replacements keep their reservation. A definitive HTTP 4xx without results releases the whole reservation.
669
+
670
+ Requires scopes: \`order:place\` and \`order:cancel\` (plus \`order:read\` when a spend cap is set). One write rate-limit unit per call.
671
+ `.trim(),
672
+ inputSchema: {
673
+ marketId: z.string().min(1),
674
+ replaces: z
675
+ .array(z.object({
676
+ orderId: z.string().regex(/^[0-9]+$/).optional(),
677
+ clientOrderId: clientOrderIdSchema.optional(),
678
+ replacementClientOrderId: clientOrderIdSchema.optional(),
679
+ price: probabilityStringSchema,
680
+ quantity: positiveDecimalStringSchema,
681
+ }))
682
+ .min(1)
683
+ .max(32),
684
+ },
685
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
686
+ handler: async (args, ctx) => {
687
+ for (const [i, leg] of args.replaces.entries()) {
688
+ const hasOrderId = leg.orderId != null;
689
+ const hasClientOrderId = leg.clientOrderId != null;
690
+ if (hasOrderId === hasClientOrderId) {
691
+ throw new Error(`replaces[${i}]: specify exactly one of orderId or clientOrderId.`);
692
+ }
693
+ }
694
+ let legDeltas = null;
695
+ let reserved = new BigNumber(0);
696
+ if (ctx.spendCap.effectiveCap() !== null) {
697
+ const openOrders = await loadOpenOrdersForSpendCap(ctx, args.marketId);
698
+ const sources = args.replaces.map((leg) => findOpenOrder(openOrders, leg));
699
+ legDeltas = [];
700
+ for (const [i, leg] of args.replaces.entries()) {
701
+ const source = sources[i];
702
+ let delta = estimateReplacementSpendDelta(source, {
703
+ price: leg.price,
704
+ quantity: leg.quantity,
705
+ });
706
+ if (delta === null) {
707
+ const target = leg.orderId != null ? `orderId=${leg.orderId}` : `clientOrderId=${leg.clientOrderId}`;
708
+ throw new SpendCapExceededError(`Refusing batch_replace_orders: a session spend cap is configured but replaces[${i}] source side/exposure ` +
709
+ `for ${target} can't be determined from open orders on this market. Ensure the order is still open and ` +
710
+ `the API key has order:read, then retry.`, ctx.spendCap.totalSpent(), ctx.spendCap.effectiveCap());
711
+ }
712
+ // Cross-outcome bid matches can spend a source's lock before its leg
713
+ // executes. Do not credit that speculative lock toward the new order.
714
+ if (source && sideOf(source) === 'bid' && sources.some((other, j) => j !== i && other && sideOf(other) === 'bid' &&
715
+ (typeof source.outcome !== 'string' || typeof other.outcome !== 'string' ||
716
+ source.outcome !== other.outcome))) {
717
+ delta = new BigNumber(leg.price).times(leg.quantity);
718
+ }
719
+ legDeltas.push(delta);
720
+ }
721
+ const total = legDeltas.reduce((sum, n) => sum.plus(n), new BigNumber(0));
722
+ if (total.gt(0)) {
723
+ ctx.spendCap.reserve(total, 'batch_replace_orders');
724
+ reserved = total;
725
+ }
726
+ }
727
+ try {
728
+ const response = await ctx.client.request({
729
+ method: 'POST',
730
+ path: '/api/v1/orders/replace/batch',
731
+ body: {
732
+ marketId: args.marketId,
733
+ replaces: args.replaces,
734
+ },
735
+ });
736
+ if (legDeltas !== null && Array.isArray(response.results)) {
737
+ let toRelease = new BigNumber(0);
738
+ const n = Math.min(legDeltas.length, response.results.length);
739
+ for (let i = 0; i < n; i++) {
740
+ if (!batchReplaceLegConsumedSpend(response.results[i])) {
741
+ toRelease = toRelease.plus(legDeltas[i]);
742
+ }
743
+ }
744
+ if (toRelease.gt(0))
745
+ ctx.spendCap.release(toRelease);
746
+ }
747
+ return response;
748
+ }
749
+ catch (err) {
750
+ if (reserved.gt(0) && err instanceof UnhedgedApiError && err.status >= 400 && err.status < 500) {
751
+ const results = resultsFromApiError(err);
752
+ if (legDeltas !== null && results) {
753
+ let toRelease = new BigNumber(0);
754
+ const n = Math.min(legDeltas.length, results.length);
755
+ for (let i = 0; i < n; i++) {
756
+ if (!batchReplaceLegConsumedSpend(results[i]))
757
+ toRelease = toRelease.plus(legDeltas[i]);
758
+ }
759
+ if (toRelease.gt(0))
760
+ ctx.spendCap.release(toRelease);
761
+ }
762
+ else {
763
+ ctx.spendCap.release(reserved);
764
+ }
765
+ }
766
+ throw err;
767
+ }
768
+ },
769
+ }),
256
770
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unhedged/mcp",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Model Context Protocol server for Unhedged. Connect Claude, Codex, Cursor, and other MCP clients to markets, balances, trading, and withdrawals through a scoped API key.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
+ "bignumber.js": "9.3.1",
32
33
  "zod": "^4.3.6"
33
34
  },
34
35
  "devDependencies": {
@@ -37,7 +37,7 @@ The user is quoting both sides of market **{{market_id}}**, outcome **{{outcome}
37
37
  - `price: fair_value - spread` (clamp to (0, 1) and round to 2 decimals)
38
38
  - `quoteQuantity: {{size_per_side}}` — must be a string with exactly two decimals, e.g. `"5.00"`
39
39
 
40
- 5. **Place an ask** with `place_order`:
40
+ 5. **Place an ask** with `place_order` (or combine steps 4–5 into one **`batch_place_orders`** call with both bid and ask legs — legs may fill, including each other when self-trade is enabled; one write rate-limit unit):
41
41
  - `side: "ask"`
42
42
  - `type: "Limit"`
43
43
  - `price: fair_value + spread` (same clamp + rounding)
@@ -47,12 +47,22 @@ The user is quoting both sides of market **{{market_id}}**, outcome **{{outcome}
47
47
 
48
48
  ## Refresh loop
49
49
 
50
- If the user asks to "refresh" or "re-quote", repeat steps 2-6. Each tool invocation receives a fresh idempotency key. If a placement fails ambiguously, check the account's transaction history before trying again: a filled order will be absent from open orders. Stop and ask the user if the outcome remains uncertain.
50
+ If the user asks to "refresh" or "re-quote":
51
+
52
+ - Prefer **`batch_replace_orders`** when both sides already have open Limit orders with known `orderId` / `clientOrderId` values: one call can move bid and ask together (replacements may fill; self-trade follows the market setting). The transaction commits even if a leg fails; HTTP 400 when any leg failed.
53
+ - Otherwise repeat steps 2–6 (`cancel_order` then place). Each tool invocation receives a fresh idempotency key. If a placement fails ambiguously, check the account's transaction history before trying again: a filled order will be absent from open orders. Stop and ask the user if the outcome remains uncertain.
51
54
 
52
55
  ## Guardrails
53
56
 
54
57
  - This is single-outcome, single-pair quoting. If the user wants to quote multiple outcomes simultaneously, run this skill once per outcome.
55
- - The session spend cap counts the bid side notional but not the ask side. Make sure the cap (`UNHEDGED_MAX_SPEND_PER_SESSION`) is generous enough for the planned cycle of re-quotes.
58
+ - The session spend cap counts bid-side place notional and **incremental** bid
59
+ notional on replace against the source's **remaining** lock (asks are free).
60
+ Same-size re-quotes of an unfilled order do not consume more of the cap;
61
+ size/price increases and replaces that rebuild after partial fills do.
62
+ Soft no-spend outcomes (`{ err }`, unchanged replace, zero-fill
63
+ `InternalCancel`) release their reservation so later quotes can reuse it.
64
+ Make sure `UNHEDGED_MAX_SPEND_PER_SESSION` covers the planned cycle of bid
65
+ upsizes.
56
66
  - Do **not** widen the spread silently to chase a fill. Tell the user what spread you're using and what the bid/ask resolve to.
57
67
  - A 403 with "missing required scope: `order:place`" means the API key wasn't minted with that scope. Tell the user to mint a new key with `order:place` and `order:cancel`.
58
68
  - **Cancel-on-disconnect (bots):** if an admin enabled `cancelOnDisconnect` on the account, long-running agents should call `POST /api/v1/orders/session/connect` at startup (UUID `sessionId`), `POST .../session/heartbeat` every ~30s, and `POST .../session/disconnect` on shutdown. API-key auth with `order:cancel` is required because the session lifecycle can cancel open CLOB orders when the last session ends.