@oobe-protocol-labs/sap-mcp-server 0.9.37 → 0.9.38

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.
Files changed (65) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +85 -0
  3. package/README.md +3 -3
  4. package/USER_DOCS/01_HOSTED_REMOTE_MCP.md +1 -1
  5. package/USER_DOCS/04_CLIENT_CONFIGS.md +3 -3
  6. package/USER_DOCS/06_DESKTOP_GUI_WIZARD.md +5 -5
  7. package/dist/config/env.d.ts +32 -0
  8. package/dist/config/env.d.ts.map +1 -1
  9. package/dist/config/env.js +24 -1
  10. package/dist/config/env.js.map +1 -1
  11. package/dist/core/constants.d.ts +1 -1
  12. package/dist/core/constants.js +1 -1
  13. package/dist/core/logger.js +1 -1
  14. package/dist/payments/pricing.d.ts.map +1 -1
  15. package/dist/payments/pricing.js +39 -1
  16. package/dist/payments/pricing.js.map +1 -1
  17. package/dist/perps/adrena/adrena-builder.d.ts +301 -0
  18. package/dist/perps/adrena/adrena-builder.d.ts.map +1 -0
  19. package/dist/perps/adrena/adrena-builder.js +1148 -0
  20. package/dist/perps/adrena/adrena-builder.js.map +1 -0
  21. package/dist/perps/adrena/adrena-constants.d.ts +135 -0
  22. package/dist/perps/adrena/adrena-constants.d.ts.map +1 -0
  23. package/dist/perps/adrena/adrena-constants.js +100 -0
  24. package/dist/perps/adrena/adrena-constants.js.map +1 -0
  25. package/dist/perps/adrena/adrena-data-api.d.ts +180 -0
  26. package/dist/perps/adrena/adrena-data-api.d.ts.map +1 -0
  27. package/dist/perps/adrena/adrena-data-api.js +169 -0
  28. package/dist/perps/adrena/adrena-data-api.js.map +1 -0
  29. package/dist/perps/adrena/adrena-pda.d.ts +146 -0
  30. package/dist/perps/adrena/adrena-pda.d.ts.map +1 -0
  31. package/dist/perps/adrena/adrena-pda.js +215 -0
  32. package/dist/perps/adrena/adrena-pda.js.map +1 -0
  33. package/dist/perps/adrena/index.d.ts +11 -0
  34. package/dist/perps/adrena/index.d.ts.map +1 -0
  35. package/dist/perps/adrena/index.js +11 -0
  36. package/dist/perps/adrena/index.js.map +1 -0
  37. package/dist/server/server-metadata.d.ts +2 -0
  38. package/dist/server/server-metadata.d.ts.map +1 -1
  39. package/dist/server/server-metadata.js +3 -1
  40. package/dist/server/server-metadata.js.map +1 -1
  41. package/dist/tools/adrena-tools.d.ts +67 -0
  42. package/dist/tools/adrena-tools.d.ts.map +1 -0
  43. package/dist/tools/adrena-tools.js +1062 -0
  44. package/dist/tools/adrena-tools.js.map +1 -0
  45. package/dist/tools/client-sdk-tools.d.ts +8 -0
  46. package/dist/tools/client-sdk-tools.d.ts.map +1 -1
  47. package/dist/tools/client-sdk-tools.js +31 -6
  48. package/dist/tools/client-sdk-tools.js.map +1 -1
  49. package/dist/tools/perp-tools.d.ts +150 -7
  50. package/dist/tools/perp-tools.d.ts.map +1 -1
  51. package/dist/tools/perp-tools.js +787 -212
  52. package/dist/tools/perp-tools.js.map +1 -1
  53. package/dist/tools/quick-context-tool.js +1 -0
  54. package/dist/tools/quick-context-tool.js.map +1 -1
  55. package/dist/tools/register-tools.d.ts.map +1 -1
  56. package/dist/tools/register-tools.js +4 -0
  57. package/dist/tools/register-tools.js.map +1 -1
  58. package/docs/07_ENDPOINTS_AND_CLIENTS.md +1 -1
  59. package/docs/10_OPERATIONS_RELEASE_AND_PM2.md +54 -6
  60. package/docs/14_DESKTOP_WIZARD_RELEASE.md +3 -3
  61. package/docs/BRANCH_AND_CI.md +2 -2
  62. package/package.json +3 -3
  63. package/server.json +3 -3
  64. package/skills/sap-defi/SKILL.md +93 -22
  65. package/skills/sap-mcp/TOOL_REFERENCE.md +39 -4
@@ -2,10 +2,10 @@
2
2
  * @name tools/perp-tools
3
3
  * @description MCP tools for perpetual futures trading and chart analysis on Solana.
4
4
  *
5
- * Provides 8 tools across two categories:
5
+ * Provides perp analytics plus explicit execution-readiness tooling:
6
6
  *
7
7
  * Read-only tools (7):
8
- * - sap_perp_markets — List Adrena perp markets with mark price, funding, OI.
8
+ * - sap_perp_markets — List Adrena markets with funding/accounting, OI, and oracle ids.
9
9
  * - sap_perp_position_info — Read on-chain perp positions for a wallet.
10
10
  * - sap_perp_funding_history — Compute funding rate from on-chain custody account.
11
11
  * - sap_chart_ohlc — OHLC candlestick data for any Solana token.
@@ -13,24 +13,28 @@
13
13
  * - sap_chart_volume_profile — Volume profile analysis (POC, VAH, VAL).
14
14
  * - sap_perp_liquidation_zones — Compute liquidation zones for open positions.
15
15
  *
16
- * Professional planning tools (1):
16
+ * Professional planning tools:
17
17
  * - sap_perp_trade_plan — Build a trader-grade risk, route, and execution checklist.
18
+ * - sap_perp_builder_status — Report whether a hosted unsigned perps builder is configured.
18
19
  *
19
- * All read-only tools use free APIs (DexScreener, DeFiLlama) and Solana RPC
20
- * (Triton). Execution builders are intentionally not exposed unless the
21
- * account graph is IDL-backed and safe for local finalization.
20
+ * Adrena reads decode Pool, Custody, and Position accounts directly from the
21
+ * official release/39 ABI layout through Solana RPC. Optional provider hooks
22
+ * can enrich reads or build unsigned execution transactions, but SAP MCP never
23
+ * pretends a perps execution route exists until the builder can return a real
24
+ * locally finalizable Solana transaction.
22
25
  *
23
26
  * @module tools/perp-tools
24
27
  */
25
28
  import { PublicKey } from '@solana/web3.js';
29
+ import bs58 from 'bs58';
26
30
  import { createTextResponse } from '../adapters/mcp/tool-response.js';
27
31
  import { registerTool } from '../adapters/mcp/sdk-compat.js';
28
32
  import { logger } from '../core/logger.js';
29
33
  /* ═══════════════════════════════════════════════════════════════════
30
34
  * Constants
31
35
  * ═══════════════════════════════════════════════════════════════════ */
32
- /** Adrena program ID on Solana mainnet. */
33
- const ADRENA_PROGRAM_ID = new PublicKey('13gDzEXCdocbj8iAiqrScGo47NiSuYENGsRqi3SEAwet');
36
+ /** Adrena program ID on Solana mainnet. Can be overridden by hosted env config. */
37
+ const DEFAULT_ADRENA_PROGRAM_ID = '13gDzEXCdocbj8iAiqrScGo47NiSuYENGsRqi3SEAwet';
34
38
  /** DexScreener REST API base URL (free, no API key). */
35
39
  const DEXSCREENER_API_URL = 'https://api.dexscreener.com';
36
40
  /** DeFiLlama REST API base URL (free, no API key). */
@@ -50,6 +54,103 @@ const DISC_POOL = Buffer.from([241, 154, 109, 4, 17, 177, 109, 188]);
50
54
  const DISC_CUSTODY = Buffer.from([1, 184, 48, 81, 93, 131, 63, 145]);
51
55
  /** Position account discriminator — sha256("account:Position")[0..8]. */
52
56
  const DISC_POSITION = Buffer.from([170, 188, 143, 228, 122, 64, 247, 208]);
57
+ /** Adrena release/39 constants from the official ABI. */
58
+ const ADRENA_USD_DECIMALS = 6;
59
+ const ADRENA_PRICE_DECIMALS = 10;
60
+ const ADRENA_BPS_DECIMALS = 4;
61
+ /** Absolute offsets include the 8-byte Anchor account discriminator. */
62
+ const ADRENA_CUSTODY_OFFSETS = {
63
+ allowTrade: 10,
64
+ allowSwap: 11,
65
+ decimals: 12,
66
+ pool: 16,
67
+ mint: 48,
68
+ tokenAccount: 80,
69
+ oracle: 112,
70
+ tradeOracle: 144,
71
+ maxInitialLeverage: 176,
72
+ maxLeverage: 180,
73
+ maxPositionLockedUsd: 184,
74
+ volumeOpenPositionUsd: 320,
75
+ tradeOiLongUsd: 360,
76
+ tradeOiShortUsd: 368,
77
+ assetsCollateral: 376,
78
+ assetsOwned: 384,
79
+ assetsLocked: 392,
80
+ longPositionsOpenCount: 400,
81
+ longPositionsSizeUsd: 408,
82
+ longPositionsBorrowSizeUsd: 416,
83
+ longPositionsLockedAmount: 424,
84
+ longPositionsCollateralUsd: 472,
85
+ shortPositionsOpenCount: 600,
86
+ shortPositionsSizeUsd: 608,
87
+ shortPositionsBorrowSizeUsd: 616,
88
+ shortPositionsLockedAmount: 624,
89
+ shortPositionsCollateralUsd: 672,
90
+ borrowRateCurrent: 800,
91
+ borrowRateLastUpdate: 808,
92
+ optimalUtilizationBps: 832,
93
+ virtualFundingMaxHourlyRate: 840,
94
+ virtualFundingMinTotalOiUsd: 848,
95
+ virtualFundingImbalanceSensitivityBps: 856,
96
+ virtualFundingCurrentLongToShort: 864,
97
+ virtualFundingLastUpdate: 872,
98
+ virtualFundingCumulativeLongToShort: 880,
99
+ virtualFundingCumulativeShortToLong: 896,
100
+ isSynthetic: 912,
101
+ version: 913,
102
+ oracleFeedId: 914,
103
+ tradeOracleFeedId: 915,
104
+ };
105
+ const ADRENA_POOL_OFFSETS = {
106
+ allowTrade: 12,
107
+ allowSwap: 13,
108
+ registeredCustodyCount: 15,
109
+ name: 16,
110
+ custodies: 48,
111
+ lpTokenPriceUsd: 328,
112
+ aumUsd: 448,
113
+ };
114
+ export const ADRENA_POSITION_OWNER_MEMCMP_OFFSET = 16;
115
+ const ADRENA_POSITION_OFFSETS = {
116
+ side: 9,
117
+ owner: ADRENA_POSITION_OWNER_MEMCMP_OFFSET,
118
+ pool: 48,
119
+ custody: 80,
120
+ collateralCustody: 112,
121
+ openTime: 144,
122
+ updateTime: 152,
123
+ price: 160,
124
+ sizeUsd: 168,
125
+ borrowSizeUsd: 176,
126
+ collateralUsd: 184,
127
+ unrealizedInterestUsd: 192,
128
+ lockedAmount: 216,
129
+ collateralAmount: 224,
130
+ exitFeeUsd: 232,
131
+ liquidationFeeUsd: 240,
132
+ id: 248,
133
+ takeProfitLimitPrice: 256,
134
+ paidInterestUsd: 264,
135
+ stopLossLimitPrice: 272,
136
+ stopLossClosePositionPrice: 280,
137
+ unrealizedFundingPaidUsd: 328,
138
+ unrealizedFundingReceivedUsd: 336,
139
+ };
140
+ const ADRENA_MAINNET_POOLS = {
141
+ '4bQRutgDJs6vuh6ZcWaPVXiQaBzbHketjbCDjL4oRN34': { name: 'main-pool', type: 'gmx' },
142
+ GN2hyBVHcUitWETeDfAoeXDMqow1x8StqdRFnGaUB2vb: { name: 'commodities-pool', type: 'autonom' },
143
+ };
144
+ const ADRENA_MAINNET_MARKETS_BY_CUSTODY = {
145
+ Dk523LZeDQbZtUwPEBjFXCd2Au1tD7mWZBJJmcgHktNk: { symbol: 'USDC', market: 'USDC', poolName: 'main-pool', kind: 'collateral' },
146
+ '8aJuzsgjxBnvRhDcfQBD7z4CUj7QoPEpaNwVd7KqsSk5': { symbol: 'BONK', market: 'BONK-PERP', poolName: 'main-pool', kind: 'perp' },
147
+ GZ9XfWwgTRhkma2Y91Q9r1XKotNXYjBnKKabj19rhT71: { symbol: 'JITOSOL', market: 'JITOSOL-PERP', poolName: 'main-pool', kind: 'perp' },
148
+ GFu3qS22mo6bAjg4Lr5R7L8pPgHq6GvbjJPKEHkbbs2c: { symbol: 'WBTC', market: 'WBTC-PERP', poolName: 'main-pool', kind: 'perp' },
149
+ woVG8fmrUzFJhWa6mRjiYC2qFCY73oAnQeioYK1m1JX: { symbol: 'USDC', market: 'USDC', poolName: 'commodities-pool', kind: 'collateral' },
150
+ JB86ouHXGYgF4UbPs8yxYdaHudrdsintf5EbBfMydzYt: { symbol: 'XAU', market: 'XAU-PERP', poolName: 'commodities-pool', kind: 'synthetic-perp' },
151
+ De21TFyUPHkvFsWAt6xJLBBXGp636VuL5cKk2DvfbHiR: { symbol: 'WTI', market: 'WTI-PERP', poolName: 'commodities-pool', kind: 'synthetic-perp' },
152
+ PexsCkkxpVmY4HNxUjT3U9PEg69kYScc8GukUwn6Q3Q: { symbol: 'XAG', market: 'XAG-PERP', poolName: 'commodities-pool', kind: 'synthetic-perp' },
153
+ };
53
154
  /* ═══════════════════════════════════════════════════════════════════
54
155
  * Helper: timed fetch
55
156
  * ═══════════════════════════════════════════════════════════════════ */
@@ -62,13 +163,14 @@ const DISC_POSITION = Buffer.from([170, 188, 143, 228, 122, 64, 247, 208]);
62
163
  *
63
164
  * @internal
64
165
  */
65
- async function timedFetch(url) {
166
+ async function timedFetch(url, options = {}) {
66
167
  try {
67
168
  const controller = new AbortController();
68
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
169
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? FETCH_TIMEOUT_MS);
69
170
  const response = await fetch(url, {
70
- method: 'GET',
71
- headers: { 'Content-Type': 'application/json' },
171
+ method: options.method ?? 'GET',
172
+ headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
173
+ body: options.body,
72
174
  signal: controller.signal,
73
175
  });
74
176
  clearTimeout(timeout);
@@ -80,6 +182,75 @@ async function timedFetch(url) {
80
182
  return null;
81
183
  }
82
184
  }
185
+ function getPerpsConfig(context) {
186
+ return context.config.perps ?? {
187
+ adrenaProgramId: DEFAULT_ADRENA_PROGRAM_ID,
188
+ apiKeyConfigured: Boolean(process.env.SAP_MCP_PERPS_API_KEY?.trim()),
189
+ timeoutMs: FETCH_TIMEOUT_MS,
190
+ };
191
+ }
192
+ function getAdrenaProgramId(context) {
193
+ return new PublicKey(getPerpsConfig(context).adrenaProgramId || DEFAULT_ADRENA_PROGRAM_ID);
194
+ }
195
+ function perpsProviderHeaders() {
196
+ const apiKey = process.env.SAP_MCP_PERPS_API_KEY?.trim();
197
+ if (!apiKey) {
198
+ return {};
199
+ }
200
+ return {
201
+ Authorization: `Bearer ${apiKey}`,
202
+ 'X-API-Key': apiKey,
203
+ };
204
+ }
205
+ function appendQuery(baseUrl, params) {
206
+ const url = new URL(baseUrl);
207
+ for (const [key, value] of Object.entries(params)) {
208
+ if (value) {
209
+ url.searchParams.set(key, value);
210
+ }
211
+ }
212
+ return url.toString();
213
+ }
214
+ export function normalizePerpProviderMarkets(payload, marketFilter = '') {
215
+ const records = Array.isArray(payload)
216
+ ? payload
217
+ : payload && typeof payload === 'object' && !Array.isArray(payload)
218
+ ? (payload.markets ?? payload.data ?? [])
219
+ : [];
220
+ const normalized = records
221
+ .filter((record) => Boolean(record) && typeof record === 'object' && !Array.isArray(record))
222
+ .map((record) => {
223
+ const symbol = String(record.symbol ?? record.market ?? '').toUpperCase();
224
+ return {
225
+ ...record,
226
+ symbol,
227
+ market: String(record.market ?? symbol),
228
+ };
229
+ });
230
+ if (!marketFilter) {
231
+ return normalized;
232
+ }
233
+ return normalized.filter((record) => record.symbol === marketFilter || String(record.market).toUpperCase() === marketFilter);
234
+ }
235
+ async function fetchConfiguredPerpMarkets(context, marketFilter) {
236
+ const perps = getPerpsConfig(context);
237
+ if (!perps.marketsUrl) {
238
+ return null;
239
+ }
240
+ const url = appendQuery(perps.marketsUrl, { market: marketFilter || undefined });
241
+ const payload = await timedFetch(url, {
242
+ headers: perpsProviderHeaders(),
243
+ timeoutMs: perps.timeoutMs,
244
+ });
245
+ if (!payload) {
246
+ return null;
247
+ }
248
+ return {
249
+ markets: normalizePerpProviderMarkets(payload, marketFilter),
250
+ source: Array.isArray(payload) ? 'configured-perps-provider' : payload.source ?? payload.venue ?? 'configured-perps-provider',
251
+ timestamp: Array.isArray(payload) ? Date.now() : payload.timestamp,
252
+ };
253
+ }
83
254
  /**
84
255
  * @name discToBase58
85
256
  * @description Convert an 8-byte discriminator Buffer to a base58 string for
@@ -90,8 +261,8 @@ async function timedFetch(url) {
90
261
  *
91
262
  * @internal
92
263
  */
93
- function discToBase58(disc) {
94
- return new PublicKey(disc).toBase58();
264
+ export function discToBase58(disc) {
265
+ return bs58.encode(disc);
95
266
  }
96
267
  /**
97
268
  * @name readU64LE
@@ -107,28 +278,262 @@ function readU64LE(buf, offset) {
107
278
  return Number(buf.readBigUInt64LE(offset));
108
279
  }
109
280
  /**
110
- * @name readSymbol
111
- * @description Read a short string (Anchor `String` 4-byte LE length + UTF-8 bytes)
281
+ * @name readI64LE
282
+ * @description Read an i64 little-endian value from a Buffer and return as a JS number.
283
+ *
284
+ * @param buf — Source buffer.
285
+ * @param offset — Read offset.
286
+ * @returns The signed value as a JavaScript number.
287
+ *
288
+ * @internal
289
+ */
290
+ function readI64LE(buf, offset) {
291
+ return Number(buf.readBigInt64LE(offset));
292
+ }
293
+ function readU128Split(buf, offset) {
294
+ const high = buf.readBigUInt64LE(offset);
295
+ const low = buf.readBigUInt64LE(offset + 8);
296
+ return (high << 64n) + low;
297
+ }
298
+ function readPublicKey(buf, offset) {
299
+ return new PublicKey(buf.subarray(offset, offset + 32)).toBase58();
300
+ }
301
+ function scaleNumber(raw, decimals) {
302
+ return Number(raw) / (10 ** decimals);
303
+ }
304
+ function readUsd(buf, offset) {
305
+ return scaleNumber(buf.readBigUInt64LE(offset), ADRENA_USD_DECIMALS);
306
+ }
307
+ function readPrice(buf, offset) {
308
+ return scaleNumber(buf.readBigUInt64LE(offset), ADRENA_PRICE_DECIMALS);
309
+ }
310
+ /**
311
+ * @name readAdrenaLimitedString
312
+ * @description Read Adrena `LimitedString` (31-byte value + 1-byte length)
112
313
  * from an account data buffer.
113
314
  *
114
315
  * @param buf — Source buffer.
115
- * @param offset — Offset to the string field.
116
- * @param maxLen — Maximum bytes to read (default 16).
316
+ * @param offset — Offset to the LimitedString field.
117
317
  * @returns Decoded symbol string, or empty string on failure.
118
318
  *
119
319
  * @internal
120
320
  */
121
- function readSymbol(buf, offset, maxLen = 16) {
321
+ export function readAdrenaLimitedString(buf, offset) {
122
322
  try {
123
- const len = buf.readUInt32LE(offset);
124
- if (len === 0 || len > maxLen)
323
+ const len = buf[offset + 31] ?? 0;
324
+ if (len === 0 || len > 31)
125
325
  return '';
126
- return buf.subarray(offset + 4, offset + 4 + len).toString('utf8').replace(/\0/g, '');
326
+ return buf.subarray(offset, offset + len).toString('utf8').replace(/\0/g, '').trim();
127
327
  }
128
328
  catch {
129
329
  return '';
130
330
  }
131
331
  }
332
+ function inferAdrenaSymbolFromOracle(oracle, fallback) {
333
+ const normalized = oracle.toUpperCase().replace(/[^A-Z0-9]/g, '');
334
+ for (const candidate of ['JITOSOL', 'WBTC', 'BONK', 'USDC', 'XAU', 'WTI', 'XAG', 'SOL', 'BTC']) {
335
+ if (normalized.includes(candidate)) {
336
+ return candidate;
337
+ }
338
+ }
339
+ return fallback;
340
+ }
341
+ function decodeAdrenaPoolAccount(pubkey, data) {
342
+ if (data.length < ADRENA_POOL_OFFSETS.custodies + 32) {
343
+ return null;
344
+ }
345
+ if (!data.subarray(0, 8).equals(DISC_POOL)) {
346
+ return null;
347
+ }
348
+ const poolAddress = pubkey.toBase58();
349
+ const registeredCustodyCount = data[ADRENA_POOL_OFFSETS.registeredCustodyCount] ?? 0;
350
+ const custodies = [];
351
+ const custodyCount = Math.min(registeredCustodyCount, 8);
352
+ for (let i = 0; i < custodyCount; i++) {
353
+ custodies.push(readPublicKey(data, ADRENA_POOL_OFFSETS.custodies + i * 32));
354
+ }
355
+ const manifestPool = ADRENA_MAINNET_POOLS[poolAddress];
356
+ return {
357
+ poolAddress,
358
+ name: readAdrenaLimitedString(data, ADRENA_POOL_OFFSETS.name) || manifestPool?.name || 'unknown-pool',
359
+ type: manifestPool?.type,
360
+ allowTrade: data[ADRENA_POOL_OFFSETS.allowTrade] !== 0,
361
+ allowSwap: data[ADRENA_POOL_OFFSETS.allowSwap] !== 0,
362
+ registeredCustodyCount,
363
+ custodies,
364
+ lpTokenPriceUsd: readUsd(data, ADRENA_POOL_OFFSETS.lpTokenPriceUsd),
365
+ aumUsd: scaleNumber(readU128Split(data, ADRENA_POOL_OFFSETS.aumUsd), ADRENA_USD_DECIMALS),
366
+ };
367
+ }
368
+ export function decodeAdrenaCustodyAccount(pubkey, data, pool) {
369
+ if (data.length < ADRENA_CUSTODY_OFFSETS.version + 1) {
370
+ return null;
371
+ }
372
+ if (!data.subarray(0, 8).equals(DISC_CUSTODY)) {
373
+ return null;
374
+ }
375
+ const custodyAddress = pubkey.toBase58();
376
+ const manifest = ADRENA_MAINNET_MARKETS_BY_CUSTODY[custodyAddress];
377
+ const poolAddress = readPublicKey(data, ADRENA_CUSTODY_OFFSETS.pool);
378
+ const custodyMint = readPublicKey(data, ADRENA_CUSTODY_OFFSETS.mint);
379
+ const oracle = readAdrenaLimitedString(data, ADRENA_CUSTODY_OFFSETS.oracle);
380
+ const tradeOracle = readAdrenaLimitedString(data, ADRENA_CUSTODY_OFFSETS.tradeOracle);
381
+ const inferredSymbol = inferAdrenaSymbolFromOracle(tradeOracle || oracle, custodyMint.slice(0, 6).toUpperCase());
382
+ const symbol = manifest?.symbol ?? inferredSymbol;
383
+ const isSynthetic = data[ADRENA_CUSTODY_OFFSETS.isSynthetic] !== 0;
384
+ const kind = manifest?.kind ?? (isSynthetic ? 'synthetic-perp' : 'unknown');
385
+ return {
386
+ symbol,
387
+ market: manifest?.market ?? (kind === 'collateral' ? symbol : `${symbol}-PERP`),
388
+ custodyAddress,
389
+ poolAddress,
390
+ poolName: pool?.name ?? manifest?.poolName ?? ADRENA_MAINNET_POOLS[poolAddress]?.name,
391
+ poolType: pool?.type ?? ADRENA_MAINNET_POOLS[poolAddress]?.type,
392
+ custodyMint,
393
+ tokenAccount: readPublicKey(data, ADRENA_CUSTODY_OFFSETS.tokenAccount),
394
+ oracle,
395
+ tradeOracle,
396
+ oracleFeedId: data[ADRENA_CUSTODY_OFFSETS.oracleFeedId] ?? 0,
397
+ tradeOracleFeedId: data[ADRENA_CUSTODY_OFFSETS.tradeOracleFeedId] ?? 0,
398
+ decimals: data[ADRENA_CUSTODY_OFFSETS.decimals] ?? 0,
399
+ allowTrade: data[ADRENA_CUSTODY_OFFSETS.allowTrade] !== 0,
400
+ allowSwap: data[ADRENA_CUSTODY_OFFSETS.allowSwap] !== 0,
401
+ isStable: data[13] !== 0,
402
+ isSynthetic,
403
+ version: data[ADRENA_CUSTODY_OFFSETS.version] ?? 0,
404
+ kind,
405
+ markPrice: null,
406
+ markPriceSource: 'not_in_custody_account',
407
+ maxInitialLeverage: data.readUInt32LE(ADRENA_CUSTODY_OFFSETS.maxInitialLeverage) / (10 ** ADRENA_BPS_DECIMALS),
408
+ maxLeverage: data.readUInt32LE(ADRENA_CUSTODY_OFFSETS.maxLeverage) / (10 ** ADRENA_BPS_DECIMALS),
409
+ maxPositionLockedUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.maxPositionLockedUsd),
410
+ openInterestLong: readUsd(data, ADRENA_CUSTODY_OFFSETS.longPositionsSizeUsd),
411
+ openInterestShort: readUsd(data, ADRENA_CUSTODY_OFFSETS.shortPositionsSizeUsd),
412
+ openPositionsLong: readU64LE(data, ADRENA_CUSTODY_OFFSETS.longPositionsOpenCount),
413
+ openPositionsShort: readU64LE(data, ADRENA_CUSTODY_OFFSETS.shortPositionsOpenCount),
414
+ borrowSizeLongUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.longPositionsBorrowSizeUsd),
415
+ borrowSizeShortUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.shortPositionsBorrowSizeUsd),
416
+ collateralLongUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.longPositionsCollateralUsd),
417
+ collateralShortUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.shortPositionsCollateralUsd),
418
+ lockedAmountLongRaw: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.longPositionsLockedAmount).toString(),
419
+ lockedAmountShortRaw: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.shortPositionsLockedAmount).toString(),
420
+ tradeOiLongUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.tradeOiLongUsd),
421
+ tradeOiShortUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.tradeOiShortUsd),
422
+ cumulativeOpenPositionUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.volumeOpenPositionUsd),
423
+ assetsRaw: {
424
+ collateral: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.assetsCollateral).toString(),
425
+ owned: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.assetsOwned).toString(),
426
+ locked: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.assetsLocked).toString(),
427
+ },
428
+ borrowRate: {
429
+ currentRateRaw: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.borrowRateCurrent).toString(),
430
+ lastUpdate: readI64LE(data, ADRENA_CUSTODY_OFFSETS.borrowRateLastUpdate),
431
+ },
432
+ funding: {
433
+ currentRateLongToShortRaw: data.readBigInt64LE(ADRENA_CUSTODY_OFFSETS.virtualFundingCurrentLongToShort).toString(),
434
+ lastUpdate: readI64LE(data, ADRENA_CUSTODY_OFFSETS.virtualFundingLastUpdate),
435
+ cumulativeLongToShortRaw: readU128Split(data, ADRENA_CUSTODY_OFFSETS.virtualFundingCumulativeLongToShort).toString(),
436
+ cumulativeShortToLongRaw: readU128Split(data, ADRENA_CUSTODY_OFFSETS.virtualFundingCumulativeShortToLong).toString(),
437
+ maxHourlyFundingRateRaw: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.virtualFundingMaxHourlyRate).toString(),
438
+ minTotalOiUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.virtualFundingMinTotalOiUsd),
439
+ imbalanceSensitivityBps: data.readUInt16LE(ADRENA_CUSTODY_OFFSETS.virtualFundingImbalanceSensitivityBps),
440
+ },
441
+ };
442
+ }
443
+ function sideFromAdrenaByte(sideByte) {
444
+ if (sideByte === 1)
445
+ return 'long';
446
+ if (sideByte === 2)
447
+ return 'short';
448
+ return null;
449
+ }
450
+ export function decodeAdrenaPositionAccount(pubkey, data, marketsByCustody = new Map()) {
451
+ if (data.length < ADRENA_POSITION_OFFSETS.unrealizedFundingReceivedUsd + 8) {
452
+ return null;
453
+ }
454
+ if (!data.subarray(0, 8).equals(DISC_POSITION)) {
455
+ return null;
456
+ }
457
+ const sideRaw = data[ADRENA_POSITION_OFFSETS.side] ?? 0;
458
+ const side = sideFromAdrenaByte(sideRaw);
459
+ if (!side) {
460
+ return null;
461
+ }
462
+ const custodyAddress = readPublicKey(data, ADRENA_POSITION_OFFSETS.custody);
463
+ const market = marketsByCustody.get(custodyAddress)?.market ?? 'unknown';
464
+ const entryPrice = readPrice(data, ADRENA_POSITION_OFFSETS.price);
465
+ const sizeUsd = readUsd(data, ADRENA_POSITION_OFFSETS.sizeUsd);
466
+ const collateralUsd = readUsd(data, ADRENA_POSITION_OFFSETS.collateralUsd);
467
+ const leverage = collateralUsd > 0 ? sizeUsd / collateralUsd : 0;
468
+ const liquidationPrice = leverage > 0
469
+ ? side === 'long'
470
+ ? entryPrice * (1 - 1 / leverage)
471
+ : entryPrice * (1 + 1 / leverage)
472
+ : 0;
473
+ const takeProfitLimitPrice = readPrice(data, ADRENA_POSITION_OFFSETS.takeProfitLimitPrice);
474
+ const stopLossLimitPrice = readPrice(data, ADRENA_POSITION_OFFSETS.stopLossLimitPrice);
475
+ const stopLossClosePositionPrice = readPrice(data, ADRENA_POSITION_OFFSETS.stopLossClosePositionPrice);
476
+ return {
477
+ positionKey: pubkey.toBase58(),
478
+ market,
479
+ side,
480
+ sideRaw,
481
+ owner: readPublicKey(data, ADRENA_POSITION_OFFSETS.owner),
482
+ poolAddress: readPublicKey(data, ADRENA_POSITION_OFFSETS.pool),
483
+ custodyAddress,
484
+ collateralCustodyAddress: readPublicKey(data, ADRENA_POSITION_OFFSETS.collateralCustody),
485
+ openTime: readI64LE(data, ADRENA_POSITION_OFFSETS.openTime),
486
+ updateTime: readI64LE(data, ADRENA_POSITION_OFFSETS.updateTime),
487
+ entryPrice,
488
+ entryPriceRaw: data.readBigUInt64LE(ADRENA_POSITION_OFFSETS.price).toString(),
489
+ size: sizeUsd,
490
+ collateral: collateralUsd,
491
+ borrowSizeUsd: readUsd(data, ADRENA_POSITION_OFFSETS.borrowSizeUsd),
492
+ leverage,
493
+ unrealizedPnl: 0,
494
+ unrealizedInterestUsd: readUsd(data, ADRENA_POSITION_OFFSETS.unrealizedInterestUsd),
495
+ unrealizedFundingPaidUsd: readUsd(data, ADRENA_POSITION_OFFSETS.unrealizedFundingPaidUsd),
496
+ unrealizedFundingReceivedUsd: readUsd(data, ADRENA_POSITION_OFFSETS.unrealizedFundingReceivedUsd),
497
+ lockedAmountRaw: data.readBigUInt64LE(ADRENA_POSITION_OFFSETS.lockedAmount).toString(),
498
+ collateralAmountRaw: data.readBigUInt64LE(ADRENA_POSITION_OFFSETS.collateralAmount).toString(),
499
+ liquidationPrice,
500
+ takeProfitLimitPrice: takeProfitLimitPrice > 0 ? takeProfitLimitPrice : null,
501
+ stopLossLimitPrice: stopLossLimitPrice > 0 ? stopLossLimitPrice : null,
502
+ stopLossClosePositionPrice: stopLossClosePositionPrice > 0 ? stopLossClosePositionPrice : null,
503
+ id: data.readBigUInt64LE(ADRENA_POSITION_OFFSETS.id).toString(),
504
+ };
505
+ }
506
+ async function readAdrenaMarketsByCustody(context) {
507
+ const adrenaProgramId = getAdrenaProgramId(context);
508
+ const [poolAccounts, custodyAccounts] = await Promise.all([
509
+ context.connection.getProgramAccounts(adrenaProgramId, {
510
+ filters: [{ memcmp: { offset: 0, bytes: discToBase58(DISC_POOL) } }],
511
+ commitment: 'confirmed',
512
+ }),
513
+ context.connection.getProgramAccounts(adrenaProgramId, {
514
+ filters: [{ memcmp: { offset: 0, bytes: discToBase58(DISC_CUSTODY) } }],
515
+ commitment: 'confirmed',
516
+ }),
517
+ ]);
518
+ const pools = new Map();
519
+ for (const { pubkey, account } of poolAccounts) {
520
+ const decoded = decodeAdrenaPoolAccount(pubkey, account.data);
521
+ if (decoded) {
522
+ pools.set(decoded.poolAddress, decoded);
523
+ }
524
+ }
525
+ const markets = new Map();
526
+ for (const { pubkey, account } of custodyAccounts) {
527
+ const poolAddress = account.data.length >= ADRENA_CUSTODY_OFFSETS.pool + 32
528
+ ? readPublicKey(account.data, ADRENA_CUSTODY_OFFSETS.pool)
529
+ : '';
530
+ const decoded = decodeAdrenaCustodyAccount(pubkey, account.data, pools.get(poolAddress));
531
+ if (decoded) {
532
+ markets.set(decoded.custodyAddress, decoded);
533
+ }
534
+ }
535
+ return markets;
536
+ }
132
537
  /* ═══════════════════════════════════════════════════════════════════
133
538
  * Tool 1: sap_perp_markets
134
539
  * ═══════════════════════════════════════════════════════════════════ */
@@ -157,132 +562,115 @@ function registerPerpMarketsTool(server, context) {
157
562
  additionalProperties: false,
158
563
  };
159
564
  registerTool(server, 'sap_perp_markets', {
160
- description: 'List available perpetual futures markets from Adrena Pool/Custody accounts with mark price, funding rate, open interest, and 24h volume when the configured RPC can serve the required account indexes. Read-only analysis tool. If markets are empty, treat it as data unavailable, not proof that markets do not exist. SAP MCP currently exposes no hosted Adrena execution builder; use sap_perp_trade_plan plus chart tools for planning only.',
565
+ description: 'List available Adrena perpetual futures markets from Pool/Custody accounts with funding/accounting fields, open interest, leverage caps, and oracle identifiers. Read-only analysis tool. Custody accounts do not expose a ready mark price, so markPrice is null until an oracle/feed decoder or configured Adrena data runtime is available. If markets are empty, treat it as data unavailable, not proof that markets do not exist. SAP MCP exposes execution only when sap_perp_builder_status reports a verified hosted unsigned builder.',
161
566
  inputSchema: schema,
162
567
  }, async (args) => {
163
568
  const marketFilter = typeof args['market'] === 'string' ? args['market'].toUpperCase() : '';
164
569
  try {
570
+ const providerMarkets = await fetchConfiguredPerpMarkets(context, marketFilter);
571
+ if (providerMarkets) {
572
+ return createTextResponse(JSON.stringify({
573
+ source: providerMarkets.source,
574
+ markets: providerMarkets.markets,
575
+ count: providerMarkets.markets.length,
576
+ timestamp: providerMarkets.timestamp ?? Date.now(),
577
+ dataAvailability: {
578
+ status: providerMarkets.markets.length > 0 ? 'available' : 'empty_from_provider',
579
+ reason: providerMarkets.markets.length > 0
580
+ ? 'Configured perps market provider returned market data.'
581
+ : 'Configured perps market provider responded but returned no matching markets for the requested filter.',
582
+ agentAction: providerMarkets.markets.length > 0
583
+ ? 'Use these records for professional planning. Before execution, call sap_perp_builder_status to verify whether a hosted unsigned builder is configured.'
584
+ : 'Do not infer that the venue has no markets. Try a broader market filter or check provider status before planning execution.',
585
+ },
586
+ recommendedNextTools: [
587
+ 'sap_perp_builder_status',
588
+ 'sap_chart_ohlc',
589
+ 'sap_chart_volume_profile',
590
+ 'sap_perp_trade_plan',
591
+ ],
592
+ executionStatus: getPerpsConfig(context).builderUrl
593
+ ? 'hosted_unsigned_builder_configured'
594
+ : 'analysis_only_no_hosted_builder',
595
+ note: 'Market data came from the configured hosted perps provider, not heuristic account decoding. Execution still requires a typed unsigned builder and local finalization.',
596
+ }, null, 2));
597
+ }
598
+ const adrenaProgramId = getAdrenaProgramId(context);
165
599
  // Fetch Custody accounts — these contain per-token price, funding rate, and OI.
166
600
  // We use memcmp on the first 8 bytes (Anchor account discriminator).
167
- const custodyAccounts = await context.connection.getProgramAccounts(ADRENA_PROGRAM_ID, {
601
+ const custodyAccounts = await context.connection.getProgramAccounts(adrenaProgramId, {
168
602
  filters: [
169
603
  { memcmp: { offset: 0, bytes: discToBase58(DISC_CUSTODY) } },
170
604
  ],
171
605
  commitment: 'confirmed',
172
606
  });
173
607
  // Fetch Pool accounts — these contain pool configuration and token info.
174
- const poolAccounts = await context.connection.getProgramAccounts(ADRENA_PROGRAM_ID, {
608
+ const poolAccounts = await context.connection.getProgramAccounts(adrenaProgramId, {
175
609
  filters: [
176
610
  { memcmp: { offset: 0, bytes: discToBase58(DISC_POOL) } },
177
611
  ],
178
612
  commitment: 'confirmed',
179
613
  });
180
- // Build a map of pool addresses for cross-referencing.
181
- const poolAddresses = new Set(poolAccounts.map(({ pubkey }) => pubkey.toBase58()));
182
- // Decode custody accounts into market data.
183
- // Custody layout (simplified, after 8-byte discriminator):
184
- // pool(32) + custodyMint(32) + symbol(String: 4+len) + decimals(u8) +
185
- // ... + pricing data (price u64, fundingRate u64, openInterestLong u64, openInterestShort u64, ...)
186
- // The exact offsets may vary by IDL version; we attempt common layouts.
187
- const markets = [];
188
- for (const { pubkey, account } of custodyAccounts) {
189
- const data = account.data;
190
- if (data.length < 8 + 32 + 32 + 4)
191
- continue;
192
- // After discriminator(8): pool(32) + custodyMint(32) = offset 72
193
- const poolPubkey = new PublicKey(data.subarray(8, 8 + 32));
194
- const custodyMint = new PublicKey(data.subarray(8 + 32, 8 + 64));
195
- const poolAddress = poolPubkey.toBase58();
196
- // Try to read symbol at offset 72 (after pool + custodyMint).
197
- // Anchor String = 4-byte LE length + UTF-8 bytes.
198
- let symbol = readSymbol(data, 8 + 64, 16);
199
- // Try alternative offsets if symbol is empty (layout may differ).
200
- if (!symbol) {
201
- // Try after pool(32) only — some IDL versions place symbol before custodyMint.
202
- symbol = readSymbol(data, 8 + 32, 16);
614
+ const poolInfoByAddress = new Map();
615
+ for (const { pubkey, account } of poolAccounts) {
616
+ const decoded = decodeAdrenaPoolAccount(pubkey, account.data);
617
+ if (decoded) {
618
+ poolInfoByAddress.set(decoded.poolAddress, decoded);
203
619
  }
204
- // Read decimals (u8) — typically right after the symbol string.
205
- // We try offset after symbol, but since symbol length varies, we also
206
- // try fixed offsets as fallback.
207
- let decimals = 6;
208
- const symbolEndOffset = 8 + 64 + 4 + (symbol ? symbol.length : 0);
209
- if (symbolEndOffset < data.length) {
210
- const dec = data[symbolEndOffset];
211
- if (dec > 0 && dec <= 18)
212
- decimals = dec;
213
- }
214
- // Price and funding data: attempt to read from deeper in the account.
215
- // Common Adrena Custody layout has pricing fields after config section.
216
- // We try multiple offsets; if reads fail, default to 0.
217
- // Pricing section typically starts around offset 120-200 depending on symbol length.
218
- let markPrice = 0;
219
- let fundingRate = 0;
220
- let openInterestLong = 0;
221
- let openInterestShort = 0;
222
- // Try reading price/funding at various known offsets.
223
- // Adrena Custody has: ... config fields ... then pricing struct with:
224
- // price(u64) + fundingRate(u64) + openInterestLong(u64) + openInterestShort(u64)
225
- // We scan for plausible price data.
226
- for (const baseOffset of [120, 144, 160, 176, 192, 200, 208, 224, 240]) {
227
- if (baseOffset + 32 > data.length)
228
- continue;
229
- const price = readU64LE(data, baseOffset);
230
- const funding = readU64LE(data, baseOffset + 8);
231
- const oiLong = readU64LE(data, baseOffset + 16);
232
- const oiShort = readU64LE(data, baseOffset + 24);
233
- // Heuristic: price should be a reasonable value (> 0, not absurdly large)
234
- // and funding should be small relative to price.
235
- if (price > 0 && price < 1e15 && Math.abs(funding) < price) {
236
- markPrice = price;
237
- fundingRate = funding;
238
- openInterestLong = oiLong;
239
- openInterestShort = oiShort;
240
- break;
241
- }
242
- }
243
- const marketEntry = {
244
- symbol: symbol || 'UNKNOWN',
245
- custodyAddress: pubkey.toBase58(),
246
- poolAddress,
247
- custodyMint: custodyMint.toBase58(),
248
- markPrice,
249
- fundingRate,
250
- openInterestLong,
251
- openInterestShort,
252
- decimals,
253
- inPool: poolAddresses.has(poolAddress),
254
- };
255
- markets.push(marketEntry);
256
620
  }
621
+ const markets = custodyAccounts
622
+ .map(({ pubkey, account }) => {
623
+ const poolAddress = account.data.length >= ADRENA_CUSTODY_OFFSETS.pool + 32
624
+ ? readPublicKey(account.data, ADRENA_CUSTODY_OFFSETS.pool)
625
+ : '';
626
+ return decodeAdrenaCustodyAccount(pubkey, account.data, poolInfoByAddress.get(poolAddress));
627
+ })
628
+ .filter((market) => Boolean(market));
257
629
  // Apply market filter if provided.
258
630
  const filtered = marketFilter
259
- ? markets.filter(m => m.symbol.toUpperCase() === marketFilter)
631
+ ? markets.filter(m => m.symbol.toUpperCase() === marketFilter || m.market.toUpperCase() === marketFilter)
260
632
  : markets;
261
- const dataAvailability = custodyAccounts.length === 0 && poolAccounts.length === 0
633
+ const scannedAccounts = custodyAccounts.length + poolAccounts.length;
634
+ const dataAvailability = scannedAccounts === 0
262
635
  ? {
263
- status: 'unavailable',
264
- reason: 'No Adrena Pool/Custody accounts were returned by the configured Solana RPC. This can mean the RPC does not serve the required getProgramAccounts index, the upstream account layout changed, or Adrena markets are unavailable for this route.',
265
- agentAction: 'Do not infer that no perp markets exist and do not retry in a loop. Use sap_chart_ohlc/sap_chart_volume_profile for market context, then stop before execution until an IDL-backed builder or native venue route is available.',
636
+ status: 'rpc_scan_empty',
637
+ reason: 'No configured perps provider is set and no Adrena Pool/Custody accounts were returned by getProgramAccounts using Anchor account discriminators. On an indexed/full-history RPC this usually means the program ID or account type discriminators are stale, not that perps do not exist.',
638
+ agentAction: 'Do not infer that no perp markets exist and do not retry in a loop. Operator should verify ADRENA_PROGRAM_ID and account discriminators against the current deployed program/IDL, or configure SAP_MCP_PERPS_MARKETS_URL with an IDL-backed provider. Use sap_chart_ohlc/sap_chart_volume_profile for market context, then stop before execution until sap_perp_builder_status says a builder is available.',
266
639
  }
267
- : {
268
- status: 'available',
269
- reason: 'Adrena account scan returned on-chain accounts from the configured RPC.',
270
- agentAction: 'Use custodyAddress and poolAddress only for analysis. SAP MCP does not expose a hosted Adrena execution builder yet.',
271
- };
640
+ : filtered.length === 0
641
+ ? {
642
+ status: 'decoded_no_matching_markets',
643
+ reason: 'The configured RPC returned Adrena Pool/Custody accounts, but no decoded market matched the requested filter. This is usually a market-filter issue.',
644
+ agentAction: 'Retry once with an empty market filter to inspect all decoded symbols. If markets decode but markPrice is null, use oracle/chart tools for live price and do not pretend custody contains a spot mark price.',
645
+ }
646
+ : {
647
+ status: 'available',
648
+ reason: 'Adrena account scan returned on-chain accounts from the configured RPC.',
649
+ agentAction: 'Use custodyAddress and poolAddress only for analysis. Call sap_perp_builder_status before any execution attempt.',
650
+ };
272
651
  return createTextResponse(JSON.stringify({
273
652
  source: 'on-chain-rpc',
274
653
  markets: filtered,
275
654
  count: filtered.length,
276
655
  totalCustodies: custodyAccounts.length,
277
656
  totalPools: poolAccounts.length,
657
+ scan: {
658
+ programId: adrenaProgramId.toBase58(),
659
+ custodyDiscriminator: discToBase58(DISC_CUSTODY),
660
+ poolDiscriminator: discToBase58(DISC_POOL),
661
+ },
278
662
  dataAvailability,
279
663
  recommendedNextTools: [
664
+ 'sap_perp_builder_status',
280
665
  'sap_chart_ohlc',
281
666
  'sap_chart_volume_profile',
282
667
  'sap_perp_trade_plan',
283
668
  ],
284
- executionStatus: 'analysis_only_no_hosted_builder',
285
- note: 'Data read directly from Solana on-chain Pool/Custody accounts. Use custodyAddress and poolAddress for analysis only. Do not execute perps unless SAP MCP exposes a typed unsigned builder or a local signer tool for that exact action.',
669
+ executionStatus: getPerpsConfig(context).builderUrl
670
+ ? 'hosted_unsigned_builder_configured'
671
+ : 'analysis_only_no_hosted_builder',
672
+ priceNote: 'Adrena Custody accounts expose market configuration, OI, funding/accounting, and oracle identifiers. They do not expose a ready-to-use mark price. markPrice is null until SAP MCP adds a typed oracle/feed decoder or configured Adrena data runtime.',
673
+ note: 'Data read directly from Solana on-chain Pool/Custody accounts using the official Adrena release/39 ABI layout. Use custodyAddress and poolAddress for analysis only. Do not execute perps unless SAP MCP exposes a typed unsigned builder or a local signer tool for that exact action.',
286
674
  }));
287
675
  }
288
676
  catch (err) {
@@ -321,7 +709,7 @@ function registerPerpPositionInfoTool(server, context) {
321
709
  additionalProperties: false,
322
710
  };
323
711
  registerTool(server, 'sap_perp_position_info', {
324
- description: 'Read all open perpetual positions on Adrena for a given wallet address. Returns position key, market, side, size, collateral, entry price, mark price, leverage, unrealized PnL, and liquidation price. Read-only — uses Solana RPC.',
712
+ description: 'Read all open perpetual positions on Adrena for a given wallet address. Returns position key, market, side, size, collateral, entry price, leverage, unrealized PnL, and liquidation price from the on-chain Position account. markPrice mirrors entryPrice until SAP MCP has a live oracle/feed price decoder. Read-only — uses Solana RPC.',
325
713
  inputSchema: schema,
326
714
  }, async (args) => {
327
715
  const walletStr = typeof args['wallet'] === 'string' ? args['wallet'] : '';
@@ -336,44 +724,39 @@ function registerPerpPositionInfoTool(server, context) {
336
724
  return createTextResponse(JSON.stringify({ error: 'Invalid wallet address' }), { isError: true });
337
725
  }
338
726
  try {
339
- // Adrena Position accounts: filter by Position discriminator + memcmp on owner.
340
- // Position layout (from IDL, after 8-byte discriminator):
341
- // owner(32) + pool(32) + custody(32) + side(1) + price(u64) + sizeUsd(u64) + collateralUsd(u64) + ...
342
- const accounts = await context.connection.getProgramAccounts(ADRENA_PROGRAM_ID, {
727
+ const accounts = await context.connection.getProgramAccounts(getAdrenaProgramId(context), {
343
728
  filters: [
344
729
  { memcmp: { offset: 0, bytes: discToBase58(DISC_POSITION) } },
345
- { memcmp: { offset: 8, bytes: walletPubkey.toBase58() } },
730
+ { memcmp: { offset: ADRENA_POSITION_OWNER_MEMCMP_OFFSET, bytes: walletPubkey.toBase58() } },
346
731
  ],
347
732
  commitment: 'confirmed',
348
733
  });
349
- const positions = accounts.map(({ pubkey, account }) => {
350
- const data = account.data;
351
- // Position layout (from IDL): owner(32) + pool(32) + custody(32) + side(1) + price(u64) + sizeUsd(u64) + collateralUsd(u64) + collateralAmount(u64) + ...
352
- const sideByte = data[8 + 32 + 32 + 32];
353
- const side = sideByte === 0 ? 'long' : 'short';
354
- const price = Number(data.readBigUInt64LE(8 + 32 + 32 + 32 + 1));
355
- const sizeUsd = Number(data.readBigUInt64LE(8 + 32 + 32 + 32 + 1 + 8));
356
- const collateralUsd = Number(data.readBigUInt64LE(8 + 32 + 32 + 32 + 1 + 8 + 8));
357
- const leverage = collateralUsd > 0 ? sizeUsd / collateralUsd : 0;
358
- const unrealizedPnl = 0;
359
- const liquidationPrice = leverage > 0 ? price * (1 - 1 / leverage) : 0;
360
- return {
361
- positionKey: pubkey.toBase58(),
362
- market: 'unknown',
363
- side,
364
- size: sizeUsd,
365
- collateral: collateralUsd,
366
- entryPrice: price,
367
- markPrice: price,
368
- leverage,
369
- unrealizedPnl,
370
- liquidationPrice,
371
- };
372
- });
734
+ const marketsByCustody = await readAdrenaMarketsByCustody(context);
735
+ const positions = accounts
736
+ .map(({ pubkey, account }) => decodeAdrenaPositionAccount(pubkey, account.data, marketsByCustody))
737
+ .filter((position) => Boolean(position))
738
+ .map((position) => ({
739
+ positionKey: position.positionKey,
740
+ market: position.market,
741
+ side: position.side,
742
+ size: position.size,
743
+ collateral: position.collateral,
744
+ entryPrice: position.entryPrice,
745
+ markPrice: position.entryPrice,
746
+ leverage: position.leverage,
747
+ unrealizedPnl: position.unrealizedPnl,
748
+ liquidationPrice: position.liquidationPrice,
749
+ }));
373
750
  return createTextResponse(JSON.stringify({
374
751
  wallet: walletStr,
375
752
  positions,
376
753
  count: positions.length,
754
+ scan: {
755
+ programId: getAdrenaProgramId(context).toBase58(),
756
+ positionDiscriminator: discToBase58(DISC_POSITION),
757
+ ownerMemcmpOffset: ADRENA_POSITION_OWNER_MEMCMP_OFFSET,
758
+ },
759
+ note: 'Positions are decoded from the official Adrena release/39 Position account layout. markPrice mirrors entryPrice until a live oracle/feed price decoder is available.',
377
760
  }));
378
761
  }
379
762
  catch (err) {
@@ -474,61 +857,51 @@ function registerPerpFundingHistoryTool(server, context) {
474
857
  message: 'The provided address is not a Custody account. Use sap_perp_markets to get the correct custodyAddress.',
475
858
  }), { isError: true });
476
859
  }
477
- // Decode funding-related fields from the Custody account.
478
- // Pool(32) + custodyMint(32) are at offset 8.
479
- const poolPubkey = new PublicKey(data.subarray(8, 8 + 32));
480
- const custodyMint = new PublicKey(data.subarray(8 + 32, 8 + 64));
481
- // Read symbol at offset 72 (after pool + custodyMint).
482
- let symbol = readSymbol(data, 8 + 64, 16);
483
- if (!symbol) {
484
- symbol = readSymbol(data, 8 + 32, 16);
860
+ const poolAddress = readPublicKey(data, ADRENA_CUSTODY_OFFSETS.pool);
861
+ const poolsByAddress = new Map();
862
+ const poolInfo = await context.connection.getAccountInfo(new PublicKey(poolAddress), 'confirmed');
863
+ if (poolInfo) {
864
+ const decodedPool = decodeAdrenaPoolAccount(new PublicKey(poolAddress), poolInfo.data);
865
+ if (decodedPool)
866
+ poolsByAddress.set(poolAddress, decodedPool);
485
867
  }
486
- // Scan for funding rate data at common offsets.
487
- let markPrice = 0;
488
- let fundingRate = 0;
489
- let openInterestLong = 0;
490
- let openInterestShort = 0;
491
- let cumulativeFunding = 0;
492
- for (const baseOffset of [120, 144, 160, 176, 192, 200, 208, 224, 240, 256]) {
493
- if (baseOffset + 48 > data.length)
494
- continue;
495
- const price = readU64LE(data, baseOffset);
496
- const funding = readU64LE(data, baseOffset + 8);
497
- const oiLong = readU64LE(data, baseOffset + 16);
498
- const oiShort = readU64LE(data, baseOffset + 24);
499
- const cumFunding = readU64LE(data, baseOffset + 32);
500
- if (price > 0 && price < 1e15 && Math.abs(funding) < price) {
501
- markPrice = price;
502
- fundingRate = funding;
503
- openInterestLong = oiLong;
504
- openInterestShort = oiShort;
505
- cumulativeFunding = cumFunding;
506
- break;
507
- }
868
+ const decoded = decodeAdrenaCustodyAccount(custodyPubkey, data, poolsByAddress.get(poolAddress));
869
+ if (!decoded) {
870
+ return createTextResponse(JSON.stringify({
871
+ error: 'Unable to decode Adrena Custody account with release/39 ABI layout',
872
+ custodyAddress: custodyAddressStr,
873
+ }), { isError: true });
508
874
  }
509
875
  const timestamp = Date.now();
510
876
  return createTextResponse(JSON.stringify({
511
877
  source: 'on-chain-rpc',
512
878
  custodyAddress: custodyAddressStr,
513
- market: market || symbol || 'UNKNOWN',
514
- symbol: symbol || 'UNKNOWN',
515
- poolAddress: poolPubkey.toBase58(),
516
- custodyMint: custodyMint.toBase58(),
879
+ market: market || decoded.market,
880
+ symbol: decoded.symbol,
881
+ poolAddress: decoded.poolAddress,
882
+ custodyMint: decoded.custodyMint,
883
+ oracle: decoded.oracle,
884
+ tradeOracle: decoded.tradeOracle,
517
885
  currentFunding: {
518
886
  timestamp,
519
- fundingRate,
520
- cumulativeFunding,
521
- markPrice,
522
- openInterestLong,
523
- openInterestShort,
887
+ fundingRateRaw: decoded.funding.currentRateLongToShortRaw,
888
+ cumulativeLongToShortRaw: decoded.funding.cumulativeLongToShortRaw,
889
+ cumulativeShortToLongRaw: decoded.funding.cumulativeShortToLongRaw,
890
+ maxHourlyFundingRateRaw: decoded.funding.maxHourlyFundingRateRaw,
891
+ fundingLastUpdate: decoded.funding.lastUpdate,
892
+ markPrice: decoded.markPrice,
893
+ markPriceSource: decoded.markPriceSource,
894
+ openInterestLong: decoded.openInterestLong,
895
+ openInterestShort: decoded.openInterestShort,
524
896
  },
525
897
  records: [{
526
898
  timestamp,
527
- fundingRate,
528
- cumulativeFunding,
899
+ fundingRateRaw: decoded.funding.currentRateLongToShortRaw,
900
+ cumulativeLongToShortRaw: decoded.funding.cumulativeLongToShortRaw,
901
+ cumulativeShortToLongRaw: decoded.funding.cumulativeShortToLongRaw,
529
902
  }],
530
903
  count: 1,
531
- note: 'On-chain data provides the current funding snapshot only. Historical time-series funding data is not available via Solana RPC use sap_perp_markets to monitor funding rate changes over time.',
904
+ note: 'On-chain Adrena Custody data provides the current funding/accounting snapshot only. Historical time-series funding data is not available via Solana RPC; use repeated snapshots or an indexed Adrena data runtime for history.',
532
905
  }));
533
906
  }
534
907
  catch (err) {
@@ -879,7 +1252,8 @@ function registerChartVolumeProfileTool(server, _context) {
879
1252
  * @description Register the sap_perp_liquidation_zones read-only tool.
880
1253
  *
881
1254
  * Reads on-chain Adrena positions for a wallet and computes liquidation
882
- * zones based on leverage, collateral, and current mark price.
1255
+ * zones from account geometry. Live current price requires a separate
1256
+ * oracle/feed decoder.
883
1257
  *
884
1258
  * @param server — MCP server instance.
885
1259
  * @param context — Runtime context with Solana RPC connection.
@@ -899,7 +1273,7 @@ function registerPerpLiquidationZonesTool(server, context) {
899
1273
  additionalProperties: false,
900
1274
  };
901
1275
  registerTool(server, 'sap_perp_liquidation_zones', {
902
- description: 'Compute liquidation zones for all open Adrena perp positions of a wallet. Reads on-chain positions via Solana RPC and calculates liquidation price, current price, and distance to liquidation. Read-only.',
1276
+ description: 'Compute liquidation zones for all open Adrena perp positions of a wallet. Reads on-chain positions via Solana RPC and calculates liquidation geometry from entry price, leverage, and liquidation price. currentPrice mirrors entryPrice until SAP MCP has a live oracle/feed price decoder. Read-only.',
903
1277
  inputSchema: schema,
904
1278
  }, async (args) => {
905
1279
  const walletStr = typeof args['wallet'] === 'string' ? args['wallet'] : '';
@@ -914,45 +1288,42 @@ function registerPerpLiquidationZonesTool(server, context) {
914
1288
  return createTextResponse(JSON.stringify({ error: 'Invalid wallet address' }), { isError: true });
915
1289
  }
916
1290
  try {
917
- const accounts = await context.connection.getProgramAccounts(ADRENA_PROGRAM_ID, {
1291
+ const accounts = await context.connection.getProgramAccounts(getAdrenaProgramId(context), {
918
1292
  filters: [
919
1293
  { memcmp: { offset: 0, bytes: discToBase58(DISC_POSITION) } },
920
- { memcmp: { offset: 8, bytes: walletPubkey.toBase58() } },
1294
+ { memcmp: { offset: ADRENA_POSITION_OWNER_MEMCMP_OFFSET, bytes: walletPubkey.toBase58() } },
921
1295
  ],
922
1296
  commitment: 'confirmed',
923
1297
  });
924
- const zones = accounts.map(({ pubkey, account }) => {
925
- const data = account.data;
926
- const sideByte = data[8 + 32 + 32 + 32];
927
- const side = sideByte === 0 ? 'long' : 'short';
928
- const entryPrice = Number(data.readBigUInt64LE(8 + 32 + 32 + 32 + 1));
929
- const sizeUsd = Number(data.readBigUInt64LE(8 + 32 + 32 + 32 + 1 + 8));
930
- const collateralUsd = Number(data.readBigUInt64LE(8 + 32 + 32 + 32 + 1 + 8 + 8));
931
- const leverage = collateralUsd > 0 ? sizeUsd / collateralUsd : 0;
932
- // Liquidation price: for long, price drops by 1/leverage; for short, price rises.
933
- const liquidationPrice = leverage > 0
934
- ? side === 'long'
935
- ? entryPrice * (1 - 1 / leverage)
936
- : entryPrice * (1 + 1 / leverage)
937
- : 0;
938
- const currentPrice = entryPrice;
939
- const distanceToLiquidationPct = liquidationPrice > 0
940
- ? Math.abs((currentPrice - liquidationPrice) / currentPrice) * 100
1298
+ const marketsByCustody = await readAdrenaMarketsByCustody(context);
1299
+ const zones = accounts
1300
+ .map(({ pubkey, account }) => decodeAdrenaPositionAccount(pubkey, account.data, marketsByCustody))
1301
+ .filter((position) => Boolean(position))
1302
+ .map((position) => {
1303
+ const currentPrice = position.entryPrice;
1304
+ const distanceToLiquidationPct = position.liquidationPrice > 0 && currentPrice > 0
1305
+ ? Math.abs((currentPrice - position.liquidationPrice) / currentPrice) * 100
941
1306
  : 0;
942
1307
  return {
943
- positionKey: pubkey.toBase58(),
944
- market: 'unknown',
945
- side,
946
- liquidationPrice,
1308
+ positionKey: position.positionKey,
1309
+ market: position.market,
1310
+ side: position.side,
1311
+ liquidationPrice: position.liquidationPrice,
947
1312
  currentPrice,
948
1313
  distanceToLiquidationPct,
949
- leverage,
1314
+ leverage: position.leverage,
950
1315
  };
951
1316
  });
952
1317
  return createTextResponse(JSON.stringify({
953
1318
  wallet: walletStr,
954
1319
  zones,
955
1320
  count: zones.length,
1321
+ scan: {
1322
+ programId: getAdrenaProgramId(context).toBase58(),
1323
+ positionDiscriminator: discToBase58(DISC_POSITION),
1324
+ ownerMemcmpOffset: ADRENA_POSITION_OWNER_MEMCMP_OFFSET,
1325
+ },
1326
+ note: 'Liquidation zones use entryPrice as currentPrice until SAP MCP has a live Adrena oracle/feed price decoder. Treat the output as risk geometry, not a liquidation alert feed.',
956
1327
  }));
957
1328
  }
958
1329
  catch (err) {
@@ -1120,12 +1491,214 @@ function registerPerpTradePlanTool(server) {
1120
1491
  }, null, 2));
1121
1492
  });
1122
1493
  }
1494
+ /* ═══════════════════════════════════════════════════════════════════
1495
+ * Tool 9: sap_perp_builder_status
1496
+ * ═══════════════════════════════════════════════════════════════════ */
1497
+ function registerPerpBuilderStatusTool(server, context) {
1498
+ const schema = {
1499
+ type: 'object',
1500
+ properties: {
1501
+ venue: {
1502
+ type: 'string',
1503
+ description: 'Optional perp venue to check, for example "adrena" or "sap-perps-provider".',
1504
+ },
1505
+ },
1506
+ additionalProperties: false,
1507
+ };
1508
+ registerTool(server, 'sap_perp_builder_status', {
1509
+ description: 'Free readiness check for perps execution. Returns whether SAP MCP has a configured hosted unsigned transaction builder for perp orders. If builderAvailable is false, agents must stop before execution and must not route direct signer-only perps tools through x402 paid-call replay.',
1510
+ inputSchema: schema,
1511
+ }, async (args) => {
1512
+ const perps = getPerpsConfig(context);
1513
+ const venue = typeof args['venue'] === 'string' ? args['venue'] : 'configured-perps-provider';
1514
+ return createTextResponse(JSON.stringify({
1515
+ venue,
1516
+ marketsProviderConfigured: Boolean(perps.marketsUrl),
1517
+ positionsProviderConfigured: Boolean(perps.positionsUrl),
1518
+ builderAvailable: Boolean(perps.builderUrl),
1519
+ adrenaProgramId: perps.adrenaProgramId,
1520
+ rpcScanMode: 'anchor-discriminator-getProgramAccounts',
1521
+ scanDiscriminators: {
1522
+ pool: discToBase58(DISC_POOL),
1523
+ custody: discToBase58(DISC_CUSTODY),
1524
+ position: discToBase58(DISC_POSITION),
1525
+ },
1526
+ builderMode: perps.builderUrl ? 'hosted_unsigned_transaction_builder' : 'not_configured',
1527
+ nativeAdrenaDecoder: {
1528
+ available: true,
1529
+ source: 'official Adrena release/39 ABI layout',
1530
+ sdkPackage: 'adrena-sdk@beta (types only, no runtime JS — using vendored IDL with @coral-xyz/anchor instead)',
1531
+ },
1532
+ nativeAdrenaBuilder: {
1533
+ available: true,
1534
+ source: 'vendored Adrena IDL (release/39) + @coral-xyz/anchor',
1535
+ operations: [
1536
+ 'sap_adrena_build_open_long',
1537
+ 'sap_adrena_build_open_short',
1538
+ 'sap_adrena_build_close_long',
1539
+ 'sap_adrena_build_close_short',
1540
+ 'sap_adrena_build_set_stop_loss',
1541
+ 'sap_adrena_build_set_take_profit',
1542
+ 'sap_adrena_build_cancel_stop_loss',
1543
+ 'sap_adrena_build_cancel_take_profit',
1544
+ 'sap_adrena_build_add_limit_order',
1545
+ 'sap_adrena_build_cancel_limit_order',
1546
+ 'sap_adrena_build_open_commodity_long',
1547
+ 'sap_adrena_build_open_commodity_short',
1548
+ 'sap_adrena_build_close_commodity_long',
1549
+ 'sap_adrena_build_close_commodity_short',
1550
+ 'sap_adrena_build_add_liquidity',
1551
+ 'sap_adrena_build_remove_liquidity',
1552
+ 'sap_adrena_build_swap',
1553
+ 'sap_adrena_build_init_user_staking',
1554
+ 'sap_adrena_build_add_liquid_stake',
1555
+ 'sap_adrena_build_remove_liquid_stake',
1556
+ 'sap_adrena_build_add_locked_stake',
1557
+ 'sap_adrena_build_claim_stakes',
1558
+ ],
1559
+ dataApi: [
1560
+ 'sap_adrena_get_positions',
1561
+ 'sap_adrena_get_pool_info',
1562
+ 'sap_adrena_get_custody_info',
1563
+ 'sap_adrena_get_trader_info',
1564
+ 'sap_adrena_get_trader_leaderboard',
1565
+ 'sap_adrena_get_mutagen',
1566
+ 'sap_adrena_get_mutagen_leaderboard',
1567
+ 'sap_adrena_get_prices',
1568
+ 'sap_adrena_get_trading_prices',
1569
+ 'sap_adrena_get_position_status',
1570
+ ],
1571
+ signerPolicy: 'All builder tools return unsigned base64 transactions. Sign locally via sap_payments_finalize_transaction. SAP MCP never signs user-owned Adrena transactions.',
1572
+ },
1573
+ signerPolicy: 'Use sap_adrena_build_* tools to construct unsigned transactions, then sign locally with sap_payments_finalize_transaction.',
1574
+ paymentPolicy: perps.builderUrl
1575
+ ? 'Calling sap_perp_build_order_transaction is a paid builder call; finalization happens locally without exposing keypair bytes.'
1576
+ : 'No x402 payment is required to learn that perps execution is unavailable.',
1577
+ nextAction: perps.builderUrl
1578
+ ? 'Use sap_perp_trade_plan, then sap_perp_build_order_transaction, then sap_payments_finalize_transaction with submit:true after user confirmation.'
1579
+ : 'Use sap_perp_trade_plan and chart tools for analysis only. Configure SAP_MCP_PERPS_BUILDER_URL only when the provider returns complete unsigned Solana transactions for local finalization.',
1580
+ }, null, 2));
1581
+ });
1582
+ }
1583
+ /* ═══════════════════════════════════════════════════════════════════
1584
+ * Optional Tool 10: sap_perp_build_order_transaction
1585
+ * ═══════════════════════════════════════════════════════════════════ */
1586
+ function registerPerpOrderBuilderTool(server, context) {
1587
+ const perps = getPerpsConfig(context);
1588
+ if (!perps.builderUrl) {
1589
+ return false;
1590
+ }
1591
+ const schema = {
1592
+ type: 'object',
1593
+ properties: {
1594
+ venue: {
1595
+ type: 'string',
1596
+ description: 'Perps venue supported by the configured builder, for example "adrena".',
1597
+ },
1598
+ owner: {
1599
+ type: 'string',
1600
+ description: 'User wallet public key that will sign and own the perp order/position.',
1601
+ },
1602
+ market: {
1603
+ type: 'string',
1604
+ description: 'Market symbol or provider market id, for example SOL-PERP.',
1605
+ },
1606
+ side: {
1607
+ type: 'string',
1608
+ description: 'Trade side for the order.',
1609
+ enum: ['long', 'short'],
1610
+ },
1611
+ orderType: {
1612
+ type: 'string',
1613
+ description: 'Order type supported by the provider. Use "market" unless the provider explicitly supports limit/trigger orders.',
1614
+ enum: ['market', 'limit', 'trigger'],
1615
+ },
1616
+ collateralMint: {
1617
+ type: 'string',
1618
+ description: 'Collateral token mint. Use USDC mint for USDC-margined perps unless the provider says otherwise.',
1619
+ },
1620
+ collateralAmount: {
1621
+ type: 'string',
1622
+ description: 'Collateral amount in smallest token units, for example micro-USDC for USDC.',
1623
+ },
1624
+ leverage: {
1625
+ type: 'number',
1626
+ description: 'Requested leverage multiplier after local policy checks.',
1627
+ minimum: 1,
1628
+ maximum: 100,
1629
+ },
1630
+ slippageBps: {
1631
+ type: 'number',
1632
+ description: 'Maximum slippage in basis points. Keep <= 100 unless user explicitly confirms higher risk.',
1633
+ minimum: 0,
1634
+ maximum: 10_000,
1635
+ },
1636
+ reduceOnly: {
1637
+ type: 'boolean',
1638
+ description: 'Set true only for reduce-only close/reduce flows.',
1639
+ },
1640
+ limitPrice: {
1641
+ type: 'number',
1642
+ description: 'Optional limit or trigger price in USD when orderType is limit or trigger.',
1643
+ minimum: 0,
1644
+ },
1645
+ clientOrderId: {
1646
+ type: 'string',
1647
+ description: 'Optional client id for idempotency/audit. Generate once and reuse on retries.',
1648
+ },
1649
+ },
1650
+ required: ['owner', 'market', 'side', 'orderType', 'collateralMint', 'collateralAmount', 'leverage'],
1651
+ additionalProperties: false,
1652
+ };
1653
+ registerTool(server, 'sap_perp_build_order_transaction', {
1654
+ description: 'Build an unsigned perps order transaction using the configured hosted perps builder. This tool is registered only when SAP_MCP_PERPS_BUILDER_URL is configured. It must return an unsigned Solana transaction for local finalization; SAP MCP never signs user-owned perps transactions on hosted.',
1655
+ inputSchema: schema,
1656
+ }, async (args) => {
1657
+ const payload = await timedFetch(perps.builderUrl, {
1658
+ method: 'POST',
1659
+ headers: perpsProviderHeaders(),
1660
+ body: JSON.stringify({
1661
+ ...args,
1662
+ requestedBy: 'sap-mcp-hosted',
1663
+ signerPolicy: 'local-finalization-required',
1664
+ }),
1665
+ timeoutMs: perps.timeoutMs,
1666
+ });
1667
+ const transactionBase64 = payload?.transactionBase64
1668
+ ?? payload?.unsignedTransactionBase64
1669
+ ?? payload?.transaction;
1670
+ if (!payload || typeof transactionBase64 !== 'string' || transactionBase64.length === 0) {
1671
+ return createTextResponse(JSON.stringify({
1672
+ error: 'perps_builder_invalid_response',
1673
+ message: 'Configured perps builder did not return transactionBase64/unsignedTransactionBase64. Execution stopped before local signing.',
1674
+ expectedNextStep: 'Fix SAP_MCP_PERPS_BUILDER_URL provider response. Do not create temporary signing scripts or hand-roll perps transactions.',
1675
+ }, null, 2), { isError: true });
1676
+ }
1677
+ return createTextResponse(JSON.stringify({
1678
+ success: true,
1679
+ venue: args['venue'] ?? 'configured-perps-provider',
1680
+ transactionBase64,
1681
+ lastValidBlockHeight: payload.lastValidBlockHeight,
1682
+ blockhash: payload.blockhash,
1683
+ feePayer: payload.feePayer,
1684
+ provider: payload.provider ?? 'configured-perps-builder',
1685
+ signerPolicy: 'local-finalization-required',
1686
+ nextTool: 'sap_payments_finalize_transaction',
1687
+ finalizeArgs: {
1688
+ transactionBase64,
1689
+ submit: false,
1690
+ },
1691
+ warnings: payload.warnings,
1692
+ }, null, 2));
1693
+ });
1694
+ return true;
1695
+ }
1123
1696
  /* ═══════════════════════════════════════════════════════════════════
1124
1697
  * Main registration function
1125
1698
  * ═══════════════════════════════════════════════════════════════════ */
1126
1699
  /**
1127
1700
  * @name registerPerpTools
1128
- * @description Register all 8 perp trading analytics and planning MCP tools.
1701
+ * @description Register perp trading analytics, planning, and optional execution-builder tools.
1129
1702
  *
1130
1703
  * @param server — MCP server instance.
1131
1704
  * @param context — Shared runtime context with SAP client, connection, and config.
@@ -1142,6 +1715,8 @@ export function registerPerpTools(server, context) {
1142
1715
  registerChartVolumeProfileTool(server, context);
1143
1716
  registerPerpLiquidationZonesTool(server, context);
1144
1717
  registerPerpTradePlanTool(server);
1145
- logger.debug('Perp tools registered', { count: 8 });
1718
+ registerPerpBuilderStatusTool(server, context);
1719
+ const builderRegistered = registerPerpOrderBuilderTool(server, context);
1720
+ logger.debug('Perp tools registered', { count: builderRegistered ? 10 : 9, builderRegistered });
1146
1721
  }
1147
1722
  //# sourceMappingURL=perp-tools.js.map