From 24c514dd691a5d337b3214019a48dbfc9e89377d Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 17:56:22 +0300 Subject: [PATCH 01/14] chore: v0.2.0 positioning sweep Version bump 0.1.12 -> 0.2.0, createRFQ baseChain/quoteChain cast (SDK type lag), README/smithery/llms-install/registry metadata refresh. Clean base for the agent-friendly Layer 1 work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + README.md | 4 ++-- llms-install.md | 2 +- package.json | 38 ++++++++++++++++++++------------------ server.json | 35 +++++++++++++++++++++++++++++++++++ smithery.yaml | 30 +++++++++++++++++++----------- src/index.ts | 6 +++++- 7 files changed, 83 insertions(+), 33 deletions(-) create mode 100644 server.json diff --git a/.gitignore b/.gitignore index aafcb23..76a8af4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ .env coverage/ *.tgz +mcp-publisher.exe diff --git a/README.md b/README.md index 735824a..8f3a510 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @hashlock-tech/mcp -> **Hashlock Markets** — trustless, non-custodial OTC trading protocol with zero slippage and DVP (delivery-vs-payment) guarantee. Swap any asset — crypto, RWAs, stablecoins — with sealed-bid RFQ price discovery and HTLC atomic settlement across Ethereum, Bitcoin, and SUI. Agent-friendly via MCP. +> **Hashlock Markets** — the atomic settlement layer for the agent economy. HTLC-based atomic settlement: live on Ethereum and Sui mainnets, with Bitcoin mainnet-ready via P2WSH HTLC scripts (no contract to deploy; signet-validated). No bridges, no custodians, no trust assumptions. Sealed-bid RFQ + HTLC fused into one atomic operation. The settlement primitive AI agents use to trade across chains. MCP-native (6 tools). > > **Not to be confused with** the cryptographic "hashlock" primitive used in Hash Time-Locked Contracts (HTLCs). This package is the MCP server for the Hashlock Markets *trading protocol and product* at [hashlock.markets](https://hashlock.markets). > @@ -13,7 +13,7 @@ ## What is this? -`@hashlock-tech/mcp` is the canonical [Model Context Protocol](https://modelcontextprotocol.io) server for **Hashlock Markets** — trustless settlement infrastructure for the autonomous economy. It lets AI agents (Claude, GPT, Cursor, Windsurf, any MCP-compatible client) create RFQs, respond as a market maker, fund HTLCs, and settle cross-chain atomic swaps across Ethereum, Bitcoin, and Sui (expanding to Base, Arbitrum, Solana, TON). +`@hashlock-tech/mcp` is the canonical [Model Context Protocol](https://modelcontextprotocol.io) server for **Hashlock Markets** — the atomic settlement layer for the agent economy. It lets AI agents (Claude, GPT, Cursor, Windsurf, any MCP-compatible client) create RFQs, respond as a market maker, fund HTLCs, and settle cross-chain atomic swaps on Ethereum and Sui mainnets, with Bitcoin mainnet-ready via P2WSH HTLC scripts (no contract to deploy; signet-validated). Expanding to Base, Arbitrum, Solana, TON. No bridges, no custodians, no trust assumptions. Hashlock Markets features 5 industry-first primitives: BTC Collateral Vaults (Sui-native via Hashi), Forward OTC Settlement (T+24h/T+48h), Verified Counterparty Directory, Multi-leg Trade Atomicity, and Execution Rewards with Tiered KYC. Three interaction modes: AI ↔ AI, AI ↔ Human, Human ↔ Human. diff --git a/llms-install.md b/llms-install.md index ca75fd1..0a561a3 100644 --- a/llms-install.md +++ b/llms-install.md @@ -2,7 +2,7 @@ ## What is this? -HashLock MCP is a Model Context Protocol server that gives AI agents access to institutional OTC crypto trading with atomic settlement (HTLCs). You can create trades, lock assets, and settle trustlessly across Ethereum and Bitcoin. +HashLock MCP is a Model Context Protocol server that gives AI agents access to cross-chain atomic settlement (HTLCs). You can create trades, lock assets, and settle trustlessly across Ethereum and Bitcoin. ## Quick Install diff --git a/package.json b/package.json index 24b462d..d89e282 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@hashlock-tech/mcp", - "version": "0.1.12", + "version": "0.2.0", "mcpName": "io.github.Hashlock-Tech/hashlock", - "description": "Hashlock Markets — trustless settlement infrastructure for the autonomous economy. Sealed-bid RFQ + HTLC atomic settlement across Ethereum, Bitcoin, Sui (+Base, Arbitrum, Solana, TON). 5 industry-first primitives. AI agent-native via MCP. NOT the cryptographic HTLC primitive. NOT affiliated with Hashlock Pty Ltd (hashlock.com).", + "description": "Hashlock Markets — atomic settlement layer for the agent economy. Sealed-bid RFQ + HTLC atomic settlement, fused into one operation. Live on Ethereum and Sui mainnets; Bitcoin HTLC mainnet-ready via P2WSH scripts (no contract to deploy; signet-validated); Base, Arbitrum, Solana, TON on the roadmap. The settlement primitive AI agents use to trade across chains. MCP-native, retry-safe, signed receipts. NOT the cryptographic HTLC primitive. NOT affiliated with Hashlock Pty Ltd (hashlock.com).", "license": "MIT", "homepage": "https://hashlock.markets", "author": { @@ -41,35 +41,37 @@ "vitest": "^4.1.4" }, "keywords": [ + "agent-economy", + "agent-native", + "ai-agent", + "autonomous-trading", "mcp", "mcp-server", "model-context-protocol", + "atomic-settlement", + "trade-and-settle", + "htlc", + "atomic-swap", + "cross-chain", + "trustless-settlement", + "non-custodial", + "sealed-bid", + "rfq", + "dvp", + "delivery-vs-payment", + "no-counterparty-risk", "hashlock", "hashlock-markets", - "htlc", - "otc", "claude", - "ai-agent", - "atomic-swap", "defi", - "sealed-bid", "intent-protocol", "crypto-trading", - "rfq", - "cross-chain", + "otc", "ethereum", "bitcoin", "sui", "stablecoin", - "rwa", - "trustless-settlement", - "non-custodial", - "zero-slippage", - "dvp", - "delivery-vs-payment", - "no-counterparty-risk", - "autonomous-economy", - "agent-friendly" + "rwa" ], "repository": { "type": "git", diff --git a/server.json b/server.json new file mode 100644 index 0000000..8cbddda --- /dev/null +++ b/server.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", + "name": "io.github.Hashlock-Tech/hashlock", + "version": "0.1.12", + "description": "Trustless cross-chain OTC settlement for AI agents. Sealed-bid RFQ + HTLC atomic swap.", + "vendor": "Hashlock-Tech", + "sourceUrl": "https://github.com/Hashlock-Tech/hashlock-mcp", + "homepage": "https://hashlock.markets", + "license": "MIT", + "runtime": "node", + "transport": ["stdio"], + "features": { + "tools": true, + "resources": false, + "prompts": false + }, + "tools": [ + { "name": "create_rfq", "description": "Create a Request for Quote to buy or sell crypto" }, + { "name": "respond_rfq", "description": "Submit a price quote for an open RFQ" }, + { "name": "create_htlc", "description": "Create and fund an HTLC for atomic OTC settlement" }, + { "name": "withdraw_htlc", "description": "Claim an HTLC by revealing the preimage" }, + { "name": "refund_htlc", "description": "Refund an HTLC after timelock expiry" }, + { "name": "get_htlc", "description": "Query HTLC status for a trade" } + ], + "installation": { + "npm": "@hashlock-tech/mcp", + "command": "npx -y @hashlock-tech/mcp", + "env": { + "HASHLOCK_ACCESS_TOKEN": { + "description": "7-day SIWE JWT from hashlock.markets/sign/login", + "required": true + } + } + } +} \ No newline at end of file diff --git a/smithery.yaml b/smithery.yaml index 55cfb46..758cf21 100644 --- a/smithery.yaml +++ b/smithery.yaml @@ -1,4 +1,4 @@ -# Smithery.ai config for HashLock OTC MCP server. +# Smithery.ai config for the Hashlock MCP server. # Published: https://www.npmjs.com/package/@hashlock-tech/mcp # Source: https://github.com/Hashlock-Tech/hashlock-mcp # Registry: io.github.Hashlock-Tech/hashlock @@ -14,7 +14,7 @@ config: properties: HASHLOCK_ACCESS_TOKEN: type: "string" - title: "HashLock access token" + title: "Hashlock access token" description: >- Bearer token obtained from https://hashlock.markets/sign/login. Issued via SIWE (Sign-In with Ethereum); valid for 7 days. @@ -22,30 +22,38 @@ config: HASHLOCK_ENDPOINT: type: "string" title: "GraphQL endpoint" - description: "Override the HashLock GraphQL endpoint. Default is production." + description: "Override the Hashlock GraphQL endpoint. Default is production." default: "https://hashlock.markets/api/graphql" metadata: name: "Hashlock Markets" description: >- - Trustless cross-chain OTC settlement for AI agents. Sealed-bid RFQ + - HTLC atomic swap. Zero slippage, zero counterparty risk, non-custodial. - DVP guarantee. ETH/BTC/SUI. Agent-friendly MCP interface for autonomous - trading. 6 tools: create_rfq, respond_rfq, create_htlc, withdraw_htlc, - refund_htlc, get_htlc. + Atomic settlement layer for the agent economy. Sealed-bid RFQ + + HTLC atomic swap, fused into one operation. Two parties on different + chains — autonomous agents or institutional OTC desks — exchange + value without trusting each other or a third party. Zero slippage, + zero counterparty risk, non-custodial. BIS DvP-1 by construction. + ETH/BTC/SUI. MCP-native, retry-safe, signed receipts. 6 tools: + create_rfq, respond_rfq, create_htlc, withdraw_htlc, refund_htlc, + get_htlc. homepage: "https://hashlock.markets" repository: "https://github.com/Hashlock-Tech/hashlock-mcp" license: "MIT" categories: - "finance" - "defi" + - "agents" - "trading" tags: + - "agent-economy" + - "agent-native" + - "atomic-settlement" - "htlc" - "atomic-swap" - - "otc" + - "cross-chain" + - "rfq" + - "dvp" - "ethereum" - "bitcoin" - "sui" - - "cross-chain" - - "rfq" + - "otc" diff --git a/src/index.ts b/src/index.ts index 91daa48..fa2106d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -170,7 +170,11 @@ server.tool( isBlind: z.boolean().optional().describe('Ghost Auction mode — hides requester identity from bidders and losing counterparties. Default false. Set true on intent words: "ghost", "blind", "anonymous", "hide identity", "gizli". External brand: "Ghost Auction"; internal name retained for API/DB schema stability.'), }, async ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind }) => { - const result = await hl.createRFQ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind }); + // TODO: SDK type def (CreateRFQInput) lags backend — baseChain/quoteChain + // are accepted by the GraphQL `createRFQ` mutation but not yet typed in + // @hashlock-tech/sdk@0.2.0. Cast to bypass DTS build; remove once SDK + // bumps the input type. Tracked separately from the v2 positioning sweep. + const result = await hl.createRFQ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind } as Parameters[0]); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, ); From cca80a2a85880ad00f8e69c0212e14118bf2b3cf Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 18:04:28 +0300 Subject: [PATCH 02/14] =?UTF-8?q?fix(get=5Fhtlc):=20use=20getHTLCs=20(htlc?= =?UTF-8?q?s=20query)=20=E2=80=94=20getHTLCStatus=20was=20broken=20for=20a?= =?UTF-8?q?ll=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getHTLCStatus queries htlcStatus.initiatorHTLC, absent from the flat HTLCStatusResult schema, so GraphQL validation rejected EVERY call. Switch to getHTLCs (htlcs query, actually served). Replace the false-green test that mocked the rejected shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/tools.test.ts | 46 ++++++++++++++++++++++--------------- src/index.ts | 15 +++++++++--- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index 4c8a27b..ed0b368 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -112,31 +112,39 @@ describe('MCP Tool → SDK Integration', () => { }); }); - // ─── get_htlc → getHTLCStatus ───────────────────────── - - describe('get_htlc (getHTLCStatus)', () => { - it('should return both initiator and counterparty HTLCs', async () => { - const htlcStatus = { - tradeId: 't-1', - status: 'BOTH_LOCKED', - initiatorHTLC: { id: 'h1', tradeId: 't-1', role: 'INITIATOR', status: 'ACTIVE', contractAddress: '0x1', hashlock: '0xh', timelock: 999, amount: '1.0', txHash: '0xa', chainType: 'evm' }, - counterpartyHTLC: { id: 'h2', tradeId: 't-1', role: 'COUNTERPARTY', status: 'ACTIVE', contractAddress: '0x2', hashlock: '0xh', timelock: 888, amount: '3500', txHash: '0xb', chainType: 'evm' }, - }; - const fetchFn = mockFetch({ data: { htlcStatus } }); + // ─── get_htlc → getHTLCs ─────────────────────────────────────────── + // get_htlc MUST use getHTLCs (queries `htlcs`, which the backend serves), + // NOT getHTLCStatus (queries htlcStatus.initiatorHTLC — a field the flat + // HTLCStatusResult type does not have; rejected at GraphQL validation for + // EVERY input). This test pins the working query shape. + + describe('get_htlc (getHTLCs)', () => { + it('returns the per-leg HTLC array for a known trade', async () => { + const htlcs = [ + { id: 'h1', tradeId: 't-1', role: 'INITIATOR', status: 'ACTIVE', contractAddress: '0x1', hashlock: '0xh', timelock: 999, amount: '1.0', txHash: '0xa', chainType: 'evm', preimage: null }, + { id: 'h2', tradeId: 't-1', role: 'COUNTERPARTY', status: 'ACTIVE', contractAddress: '0x2', hashlock: '0xh', timelock: 888, amount: '3500', txHash: '0xb', chainType: 'evm', preimage: null }, + ]; + const fetchFn = mockFetch({ data: { htlcs } }); const hl = createSDK(fetchFn); - const result = await hl.getHTLCStatus('t-1'); - expect(result?.status).toBe('BOTH_LOCKED'); - expect(result?.initiatorHTLC?.status).toBe('ACTIVE'); - expect(result?.counterpartyHTLC?.status).toBe('ACTIVE'); + const result = await hl.getHTLCs('t-1'); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0].role).toBe('INITIATOR'); + expect(result[1].role).toBe('COUNTERPARTY'); + const body = JSON.parse(fetchFn.mock.calls[0][1].body); + expect(body.query).toContain('htlcs(tradeId'); + expect(body.query).not.toContain('initiatorHTLC'); + expect(body.variables.tradeId).toBe('t-1'); }); - it('should return null for unknown trade', async () => { - const fetchFn = mockFetch({ data: { htlcStatus: null } }); + it('returns an empty array for an unknown trade (clean not-found signal)', async () => { + const fetchFn = mockFetch({ data: { htlcs: [] } }); const hl = createSDK(fetchFn); - const result = await hl.getHTLCStatus('unknown'); - expect(result).toBeNull(); + const result = await hl.getHTLCs('unknown-trade-id'); + expect(result).toEqual([]); }); }); diff --git a/src/index.ts b/src/index.ts index fa2106d..fcdc49a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,12 +84,21 @@ server.tool( server.tool( 'get_htlc', - 'Real-time trade observability — settlement status, timelock countdown, preimage reveal status across chains. Query live HTLC status for a trade — both initiator and counterparty legs, contract addresses, lock amounts, timelocks. USE WHEN: displaying status, deciding next action, or building audit trails. Safe to call at any time — read-only. Cross-chain: ETH, BTC, SUI.', + [ + 'Real-time trade observability — per-leg HTLC settlement state for a trade: which legs are locked, on which chain, with what timelock, and whether the preimage has been revealed. Read-only, safe to call at any time.', + '', + 'Returns an ARRAY of HTLC legs (one entry per locked leg, typically the initiator leg and the counterparty leg). An empty array means no HTLC has been recorded for this tradeId yet (or the tradeId does not exist) — treat empty as "nothing locked", not an error.', + '', + 'USE WHEN: showing trade/settlement status to the user, deciding the next settlement action (lock / claim / refund), polling for the counterparty leg, or rebuilding state after losing context.', + 'DO NOT USE WHEN: you need RFQ/quote status (this is settlement-leg state only) — use list_my_trades or list_open_rfqs instead.', + '', + 'INTERPRETING THE RESULT (per leg): `role` = INITIATOR | COUNTERPARTY; `status` = leg lifecycle; `chainType` = evm | bitcoin | sui; `timelock` = unix expiry of that leg; `preimage` non-null on a claimed initiator leg. Both legs ACTIVE = swap can complete (claim path). Initiator leg past `timelock` with counterparty leg absent = refund path.', + ].join('\n'), { - tradeId: z.string().describe('Trade ID to query HTLC status for'), + tradeId: z.string().describe('Trade ID to query HTLC legs for. An unknown ID returns an empty array, not an error.'), }, async ({ tradeId }) => { - const result = await hl.getHTLCStatus(tradeId); + const result = await hl.getHTLCs(tradeId); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, ); From 6f0307a40ceb14709ec4307b59bfbe700436e2f7 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 18:13:52 +0300 Subject: [PATCH 03/14] refactor: extract okContent tool-content helper (testable handler shape) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/result.test.ts | 9 +++++++++ src/index.ts | 13 +++++++------ src/lib/result.ts | 10 ++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/result.test.ts create mode 100644 src/lib/result.ts diff --git a/src/__tests__/result.test.ts b/src/__tests__/result.test.ts new file mode 100644 index 0000000..b7e27a6 --- /dev/null +++ b/src/__tests__/result.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; +import { okContent } from '../lib/result.js'; + +describe('okContent', () => { + it('wraps a value as pretty JSON MCP text content', () => { + const out = okContent({ a: 1 }); + expect(out).toEqual({ content: [{ type: 'text', text: JSON.stringify({ a: 1 }, null, 2) }] }); + }); +}); diff --git a/src/index.ts b/src/index.ts index fcdc49a..7adf8c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import { HashLock } from '@hashlock-tech/sdk'; +import { okContent } from './lib/result.js'; // Default to the direct api-gateway endpoint (/graphql), NOT the browser-only // SSR proxy at /api/graphql. The SSR proxy reads the httpOnly `api-token` @@ -43,7 +44,7 @@ server.tool( }, async ({ tradeId, txHash, role, timelock, hashlock, chainType, preimage }) => { const result = await hl.fundHTLC({ tradeId, txHash, role, timelock, hashlock, chainType, preimage }); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + return okContent(result); }, ); @@ -60,7 +61,7 @@ server.tool( }, async ({ tradeId, txHash, preimage, chainType }) => { const result = await hl.claimHTLC({ tradeId, txHash, preimage, chainType }); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + return okContent(result); }, ); @@ -76,7 +77,7 @@ server.tool( }, async ({ tradeId, txHash, chainType }) => { const result = await hl.refundHTLC({ tradeId, txHash, chainType }); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + return okContent(result); }, ); @@ -99,7 +100,7 @@ server.tool( }, async ({ tradeId }) => { const result = await hl.getHTLCs(tradeId); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + return okContent(result); }, ); @@ -184,7 +185,7 @@ server.tool( // @hashlock-tech/sdk@0.2.0. Cast to bypass DTS build; remove once SDK // bumps the input type. Tracked separately from the v2 positioning sweep. const result = await hl.createRFQ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind } as Parameters[0]); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + return okContent(result); }, ); @@ -201,7 +202,7 @@ server.tool( }, async ({ rfqId, price, amount, expiresIn }) => { const result = await hl.submitQuote({ rfqId, price, amount, expiresIn }); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + return okContent(result); }, ); diff --git a/src/lib/result.ts b/src/lib/result.ts new file mode 100644 index 0000000..0e805dc --- /dev/null +++ b/src/lib/result.ts @@ -0,0 +1,10 @@ +/** + * MCP tool content helpers. Pure + unit-testable so handler-shape behavior + * (success envelope, and the error envelope built on top in lib/errors.ts) + * can be asserted without booting the MCP stdio server. + */ +export type ToolContent = { content: { type: 'text'; text: string }[] }; + +export function okContent(value: unknown): ToolContent { + return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }; +} From 4c93e4e62cad80d35867bc8e66c29fb368579772 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 18:22:38 +0300 Subject: [PATCH 04/14] feat(errors): structured error envelope across all tools Heuristic classifier (UNAUTHORIZED/RATE_LIMITED/UPSTREAM_RPC_ERROR/RFQ_EXPIRED/NO_LIQUIDITY/TRADE_NOT_FOUND/VALIDATION_ERROR/UNKNOWN) with recovery hints; UNKNOWN never masks the original message. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/errors.test.ts | 54 ++++++++++++++++++++++++++++ src/index.ts | 25 ++++++------- src/lib/errors.ts | 70 ++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/errors.test.ts create mode 100644 src/lib/errors.ts diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts new file mode 100644 index 0000000..bc0b632 --- /dev/null +++ b/src/__tests__/errors.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { classifyError, wrapTool } from '../lib/errors.js'; + +describe('classifyError', () => { + it('maps GraphQL field-validation / not-found language to TRADE_NOT_FOUND', () => { + const c = classifyError(new Error('No trade found for tradeId xyz')); + expect(c.code).toBe('TRADE_NOT_FOUND'); + expect(c.is_retryable).toBe(false); + expect(c.recovery_hint).toMatch(/list_my_trades|verify the tradeId/i); + }); + + it('maps Unauthorized to UNAUTHORIZED', () => { + expect(classifyError(new Error('Unauthorized – missing api-token')).code).toBe('UNAUTHORIZED'); + }); + + it('maps HTTP 429 / rate language to RATE_LIMITED (retryable)', () => { + const c = classifyError(new Error('Request failed: 429 Too Many Requests')); + expect(c.code).toBe('RATE_LIMITED'); + expect(c.is_retryable).toBe(true); + }); + + it('maps 5xx / rpc language to UPSTREAM_RPC_ERROR (retryable)', () => { + const c = classifyError(new Error('Request failed: 502 Bad Gateway')); + expect(c.code).toBe('UPSTREAM_RPC_ERROR'); + expect(c.is_retryable).toBe(true); + }); + + it('maps expired RFQ language to RFQ_EXPIRED', () => { + expect(classifyError(new Error('RFQ has expired')).code).toBe('RFQ_EXPIRED'); + }); + + it('falls back to UNKNOWN without masking the original message', () => { + const c = classifyError(new Error('totally novel boom')); + expect(c.code).toBe('UNKNOWN'); + expect(c.is_retryable).toBe(false); + }); +}); + +describe('wrapTool', () => { + it('passes through a successful handler result unchanged', async () => { + const wrapped = wrapTool(async (x: number) => ({ content: [{ type: 'text', text: String(x) }] })); + expect(await wrapped(5)).toEqual({ content: [{ type: 'text', text: '5' }] }); + }); + + it('converts a thrown error into a structured envelope as tool content', async () => { + const wrapped = wrapTool(async () => { throw new Error('No trade found for tradeId zzz'); }); + const out = await wrapped(); + const payload = JSON.parse(out.content[0].text); + expect(payload.error.code).toBe('TRADE_NOT_FOUND'); + expect(payload.error.is_retryable).toBe(false); + expect(typeof payload.error.recovery_hint).toBe('string'); + expect(payload.error.message).toContain('No trade found'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 7adf8c5..cd61db1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod'; import { HashLock } from '@hashlock-tech/sdk'; import { okContent } from './lib/result.js'; +import { wrapTool } from './lib/errors.js'; // Default to the direct api-gateway endpoint (/graphql), NOT the browser-only // SSR proxy at /api/graphql. The SSR proxy reads the httpOnly `api-token` @@ -42,10 +43,10 @@ server.tool( chainType: z.string().optional().describe('Chain type: evm, bitcoin, or sui'), preimage: z.string().optional().describe('Secret preimage (only for initiator)'), }, - async ({ tradeId, txHash, role, timelock, hashlock, chainType, preimage }) => { + wrapTool(async ({ tradeId, txHash, role, timelock, hashlock, chainType, preimage }) => { const result = await hl.fundHTLC({ tradeId, txHash, role, timelock, hashlock, chainType, preimage }); return okContent(result); - }, + }), ); // ─── withdraw_htlc ─────────────────────────────────────────── @@ -59,10 +60,10 @@ server.tool( preimage: z.string().describe('The 32-byte secret preimage (0x-prefixed hex)'), chainType: z.string().optional().describe('Chain type: evm, bitcoin, or sui'), }, - async ({ tradeId, txHash, preimage, chainType }) => { + wrapTool(async ({ tradeId, txHash, preimage, chainType }) => { const result = await hl.claimHTLC({ tradeId, txHash, preimage, chainType }); return okContent(result); - }, + }), ); // ─── refund_htlc ───────────────────────────────────────────── @@ -75,10 +76,10 @@ server.tool( txHash: z.string().describe('On-chain refund transaction hash (0x-prefixed)'), chainType: z.string().optional().describe('Chain type: evm, bitcoin, or sui'), }, - async ({ tradeId, txHash, chainType }) => { + wrapTool(async ({ tradeId, txHash, chainType }) => { const result = await hl.refundHTLC({ tradeId, txHash, chainType }); return okContent(result); - }, + }), ); // ─── get_htlc ──────────────────────────────────────────────── @@ -98,10 +99,10 @@ server.tool( { tradeId: z.string().describe('Trade ID to query HTLC legs for. An unknown ID returns an empty array, not an error.'), }, - async ({ tradeId }) => { + wrapTool(async ({ tradeId }) => { const result = await hl.getHTLCs(tradeId); return okContent(result); - }, + }), ); // ─── create_rfq ────────────────────────────────────────────── @@ -179,14 +180,14 @@ server.tool( expiresIn: z.number().optional().describe('RFQ expiration in seconds. Default 300 (5 min). "Urgent" → 60-120. "Take your time" → 600-1800. Hard cap 86400 (24 h).'), isBlind: z.boolean().optional().describe('Ghost Auction mode — hides requester identity from bidders and losing counterparties. Default false. Set true on intent words: "ghost", "blind", "anonymous", "hide identity", "gizli". External brand: "Ghost Auction"; internal name retained for API/DB schema stability.'), }, - async ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind }) => { + wrapTool(async ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind }) => { // TODO: SDK type def (CreateRFQInput) lags backend — baseChain/quoteChain // are accepted by the GraphQL `createRFQ` mutation but not yet typed in // @hashlock-tech/sdk@0.2.0. Cast to bypass DTS build; remove once SDK // bumps the input type. Tracked separately from the v2 positioning sweep. const result = await hl.createRFQ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind } as Parameters[0]); return okContent(result); - }, + }), ); // ─── respond_rfq ───────────────────────────────────────────── @@ -200,10 +201,10 @@ server.tool( amount: z.string().describe('Amount of base token to offer'), expiresIn: z.number().optional().describe('Quote expiration in seconds'), }, - async ({ rfqId, price, amount, expiresIn }) => { + wrapTool(async ({ rfqId, price, amount, expiresIn }) => { const result = await hl.submitQuote({ rfqId, price, amount, expiresIn }); return okContent(result); - }, + }), ); // ─── Start server ──────────────────────────────────────────── diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..6b745ae --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,70 @@ +import { okContent, type ToolContent } from './result.js'; + +export type ErrorCode = + | 'TRADE_NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' + | 'RATE_LIMITED' | 'UPSTREAM_RPC_ERROR' | 'RFQ_EXPIRED' + | 'NO_LIQUIDITY' | 'UNKNOWN'; + +export interface Classification { + code: ErrorCode; + is_retryable: boolean; + recovery_hint: string; +} + +const RULES: { test: RegExp; code: ErrorCode; is_retryable: boolean; recovery_hint: string }[] = [ + { test: /unauthor|missing api-token|forbidden|401/i, code: 'UNAUTHORIZED', is_retryable: false, + recovery_hint: 'Set a valid HASHLOCK_ACCESS_TOKEN (bearer from hashlock.markets/sign/login) and retry.' }, + { test: /429|too many requests|rate.?limit/i, code: 'RATE_LIMITED', is_retryable: true, + recovery_hint: 'Back off and retry after a short delay.' }, + { test: /50\d|bad gateway|gateway timeout|upstream|rpc|econnreset|fetch failed|network/i, code: 'UPSTREAM_RPC_ERROR', is_retryable: true, + recovery_hint: 'Transient upstream/RPC failure. Retry with backoff; if persistent, the backend or a chain RPC is degraded.' }, + { test: /rfq.*expire|expired.*rfq|quote.*expired/i, code: 'RFQ_EXPIRED', is_retryable: false, + recovery_hint: 'The RFQ/quote window closed. Create a fresh RFQ with create_rfq.' }, + { test: /no (liquidity|maker|quote)|insufficient liquidity|no counterparty/i, code: 'NO_LIQUIDITY', is_retryable: false, + recovery_hint: 'No market-maker coverage for this size/pair. Try a smaller size, a major pair, or widen expiresIn.' }, + { test: /not found|no trade|unknown trade|does not exist|cannot query field/i, code: 'TRADE_NOT_FOUND', is_retryable: false, + recovery_hint: 'Verify the tradeId/rfqId via list_my_trades or list_open_rfqs, or re-create the request.' }, + { test: /invalid|validation|must be|required|bad request|400|unsupported|not a valid/i, code: 'VALIDATION_ERROR', is_retryable: false, + recovery_hint: 'Fix the offending argument and retry. Check token/chain are in list_supported_pairs.' }, +]; + +export function classifyError(err: unknown): Classification { + const message = err instanceof Error ? err.message : String(err); + for (const r of RULES) { + if (r.test.test(message)) { + return { code: r.code, is_retryable: r.is_retryable, recovery_hint: r.recovery_hint }; + } + } + return { + code: 'UNKNOWN', + is_retryable: false, + recovery_hint: 'Unrecognized failure. Inspect the message; do not blindly retry a write.', + }; +} + +export function toErrorEnvelope(err: unknown): ToolContent { + const message = err instanceof Error ? err.message : String(err); + const c = classifyError(err); + return okContent({ + error: { + code: c.code, + message, + is_retryable: c.is_retryable, + recovery_hint: c.recovery_hint, + details: {}, + }, + }); +} + +/** Wrap an MCP tool handler so thrown errors become a structured envelope. */ +export function wrapTool( + handler: (...args: A) => Promise, +): (...args: A) => Promise { + return async (...args: A) => { + try { + return await handler(...args); + } catch (err) { + return toErrorEnvelope(err); + } + }; +} From a27f8d959473c5fc67050dc8faf4fbcaec1e03ca Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 18:38:29 +0300 Subject: [PATCH 05/14] fix(errors): tighten UPSTREAM regex; document deliberate non-isError envelope Bare /50\d/ and /network/ misclassified validation errors (e.g. 'amount 500 invalid', 'unsupported network') as retryable UPSTREAM_RPC_ERROR. Scope to 5xx phrases/status-anchored numerics + specific network-failure phrases; validation phrases now fall through to VALIDATION_ERROR (non-retryable). Add negative + non-Error-throw tests. Document that the error envelope is intentionally structured content, not MCP isError. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/errors.test.ts | 32 ++++++++++++++++++++++++++++++++ src/lib/errors.ts | 20 ++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts index bc0b632..1570e9a 100644 --- a/src/__tests__/errors.test.ts +++ b/src/__tests__/errors.test.ts @@ -34,6 +34,30 @@ describe('classifyError', () => { expect(c.code).toBe('UNKNOWN'); expect(c.is_retryable).toBe(false); }); + + // Issue 1 regression tests: bare /50\d/ and bare /network/ false-positives + it('does NOT classify "amount 500 invalid" as UPSTREAM — bare 50x must not match validation messages', () => { + const c = classifyError(new Error('amount 500 invalid')); + expect(c.code).toBe('VALIDATION_ERROR'); + expect(c.is_retryable).toBe(false); + }); + + it('does NOT classify "unsupported network" as UPSTREAM — bare /network/ must not match validation phrases', () => { + const c = classifyError(new Error('unsupported network')); + expect(c.code).toBe('VALIDATION_ERROR'); + }); + + it('still classifies transient "network request failed" as UPSTREAM_RPC_ERROR (retryable)', () => { + const c = classifyError(new Error('network request failed')); + expect(c.code).toBe('UPSTREAM_RPC_ERROR'); + expect(c.is_retryable).toBe(true); + }); + + it('still classifies "502 Bad Gateway" as UPSTREAM_RPC_ERROR (retryable) — regression guard', () => { + const c = classifyError(new Error('Request failed: 502 Bad Gateway')); + expect(c.code).toBe('UPSTREAM_RPC_ERROR'); + expect(c.is_retryable).toBe(true); + }); }); describe('wrapTool', () => { @@ -51,4 +75,12 @@ describe('wrapTool', () => { expect(typeof payload.error.recovery_hint).toBe('string'); expect(payload.error.message).toContain('No trade found'); }); + + it('converts a non-Error throw (plain string) into a structured envelope', async () => { + const wrapped = wrapTool(async () => { throw 'plain string boom'; }); + const out = await wrapped(); + const payload = JSON.parse(out.content[0].text); + expect(payload.error.message).toContain('plain string boom'); + expect(payload.error.code).toBe('UNKNOWN'); + }); }); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 6b745ae..c46b890 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -16,7 +16,7 @@ const RULES: { test: RegExp; code: ErrorCode; is_retryable: boolean; recovery_hi recovery_hint: 'Set a valid HASHLOCK_ACCESS_TOKEN (bearer from hashlock.markets/sign/login) and retry.' }, { test: /429|too many requests|rate.?limit/i, code: 'RATE_LIMITED', is_retryable: true, recovery_hint: 'Back off and retry after a short delay.' }, - { test: /50\d|bad gateway|gateway timeout|upstream|rpc|econnreset|fetch failed|network/i, code: 'UPSTREAM_RPC_ERROR', is_retryable: true, + { test: /(?:status|code|http|failed:?)\s*5\d{2}\b|\b5\d{2}\b\s*(?:internal server error|bad gateway|service unavailable|gateway timeout)|internal server error|bad gateway|service unavailable|gateway timeout|upstream|\brpc\b|econnreset|etimedout|fetch failed|network (?:error|request failed|timeout)/i, code: 'UPSTREAM_RPC_ERROR', is_retryable: true, recovery_hint: 'Transient upstream/RPC failure. Retry with backoff; if persistent, the backend or a chain RPC is degraded.' }, { test: /rfq.*expire|expired.*rfq|quote.*expired/i, code: 'RFQ_EXPIRED', is_retryable: false, recovery_hint: 'The RFQ/quote window closed. Create a fresh RFQ with create_rfq.' }, @@ -42,6 +42,18 @@ export function classifyError(err: unknown): Classification { }; } +/** + * Converts any thrown value into a structured error envelope returned as normal + * tool content (via `okContent`). This deliberately does NOT set MCP `isError:true`. + * + * Design rationale: returning the envelope as structured content — rather than as + * an MCP protocol error — gives autonomous agents a machine-readable + * `{ error: { code, message, is_retryable, recovery_hint } }` payload they can + * branch on without parsing free-form error text. An MCP `isError:true` response + * would surface as an opaque protocol fault to most agent runtimes, losing the + * actionable classification. Do NOT add `isError:true` here; do NOT modify + * `result.ts` to inject it. + */ export function toErrorEnvelope(err: unknown): ToolContent { const message = err instanceof Error ? err.message : String(err); const c = classifyError(err); @@ -56,7 +68,11 @@ export function toErrorEnvelope(err: unknown): ToolContent { }); } -/** Wrap an MCP tool handler so thrown errors become a structured envelope. */ +/** + * Wrap an MCP tool handler so thrown errors become a structured envelope. + * Errors are returned as normal tool content (not MCP `isError:true`) — see + * `toErrorEnvelope` for the rationale. + */ export function wrapTool( handler: (...args: A) => Promise, ): (...args: A) => Promise { From d960d976d30da5849d750a4839f5452bdfbed06d Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 18:45:46 +0300 Subject: [PATCH 06/14] feat(discovery): SUPPORTED_PAIRS constant + list_supported_pairs tool Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/pairs.test.ts | 13 +++++++++++++ src/index.ts | 17 +++++++++++++++++ src/lib/pairs.ts | 12 ++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 src/__tests__/pairs.test.ts create mode 100644 src/lib/pairs.ts diff --git a/src/__tests__/pairs.test.ts b/src/__tests__/pairs.test.ts new file mode 100644 index 0000000..60599e6 --- /dev/null +++ b/src/__tests__/pairs.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest'; +import { SUPPORTED_PAIRS, SUPPORTED_PAIRS_LINE } from '../lib/pairs.js'; + +describe('SUPPORTED_PAIRS', () => { + it('contains every chain-qualified pair the create_rfq description pins', () => { + for (const p of ['ETH/sepolia','ETH/ethereum','BTC/bitcoin-signet','BTC/bitcoin','USDC/sepolia','USDC/ethereum','USDT/ethereum','WBTC/ethereum','WETH/ethereum','SUI/sui','SUI/sui-testnet']) { + expect(SUPPORTED_PAIRS).toContain(p); + } + }); + it('exposes a comma-joined line for prose embedding', () => { + expect(SUPPORTED_PAIRS_LINE).toBe(SUPPORTED_PAIRS.join(', ') + '.'); + }); +}); diff --git a/src/index.ts b/src/index.ts index cd61db1..06806c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { HashLock } from '@hashlock-tech/sdk'; import { okContent } from './lib/result.js'; import { wrapTool } from './lib/errors.js'; +import { SUPPORTED_PAIRS } from './lib/pairs.js'; // Default to the direct api-gateway endpoint (/graphql), NOT the browser-only // SSR proxy at /api/graphql. The SSR proxy reads the httpOnly `api-token` @@ -190,6 +191,22 @@ server.tool( }), ); +// ─── list_supported_pairs ──────────────────────────────────── + +server.tool( + 'list_supported_pairs', + [ + 'List the chain-qualified token pairs Hashlock supports for RFQ/swap. Read-only, no auth side effects.', + '', + 'USE WHEN: before create_rfq if unsure a token/chain is supported, or to show the user available markets instead of guessing.', + 'DO NOT USE WHEN: you already know the pair is supported — this is discovery, not a precondition.', + '', + 'Each entry is SYMBOL/chain. Same symbol on different chains (e.g. SUI/sui vs SUI/sui-testnet) are distinct markets — pass baseChain/quoteChain explicitly to create_rfq.', + ].join('\n'), + {}, + wrapTool(async () => okContent({ pairs: SUPPORTED_PAIRS })), +); + // ─── respond_rfq ───────────────────────────────────────────── server.tool( diff --git a/src/lib/pairs.ts b/src/lib/pairs.ts new file mode 100644 index 0000000..2c59460 --- /dev/null +++ b/src/lib/pairs.ts @@ -0,0 +1,12 @@ +/** Canonical chain-qualified pairs. Single source of truth for the + * list_supported_pairs tool (and future use). The create_rfq description + * keeps its own prose copy intentionally — its pin tests assert that text. */ +export const SUPPORTED_PAIRS = [ + 'ETH/sepolia', 'ETH/ethereum', + 'BTC/bitcoin-signet', 'BTC/bitcoin', + 'USDC/sepolia', 'USDC/ethereum', + 'USDT/ethereum', 'WBTC/ethereum', 'WETH/ethereum', + 'SUI/sui', 'SUI/sui-testnet', +] as const; + +export const SUPPORTED_PAIRS_LINE = SUPPORTED_PAIRS.join(', ') + '.'; From f5fa5614d88d596a72940f19241fc1a3726c0458 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 18:53:19 +0300 Subject: [PATCH 07/14] feat(discovery): list_open_rfqs + list_my_trades read-only tools Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/tools.test.ts | 26 +++++++++++++++++++++++ src/index.ts | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index ed0b368..1482120 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -442,6 +442,32 @@ describe('MCP Tool → SDK Integration', () => { }); }); + // ─── list_open_rfqs → listRFQs ──────────────────────── + + describe('list_open_rfqs (listRFQs)', () => { + it('requests ACTIVE rfqs with pagination', async () => { + const fetchFn = mockFetch({ data: { rfqs: { rfqs: [{ id: 'r1', userId: 'u1', baseToken: 'ETH', quoteToken: 'USDC', side: 'SELL', amount: '2', isBlind: false, status: 'ACTIVE', expiresAt: null, createdAt: '2026-05-18', quotesCount: 0 }], total: 1, page: 1, pageSize: 20 } } }); + const hl = createSDK(fetchFn); + const result = await hl.listRFQs({ status: 'ACTIVE', page: 1, pageSize: 20 }); + expect(result.rfqs).toHaveLength(1); + const body = JSON.parse(fetchFn.mock.calls[0][1].body); + expect(body.variables.status).toBe('ACTIVE'); + expect(body.variables.pageSize).toBe(20); + }); + }); + + // ─── list_my_trades → listTrades ────────────────────── + + describe('list_my_trades (listTrades)', () => { + it('passes an optional status filter through', async () => { + const fetchFn = mockFetch({ data: { trades: { trades: [], total: 0 } } }); + const hl = createSDK(fetchFn); + await hl.listTrades({ status: 'ACTIVE', page: 1, pageSize: 20 }); + const body = JSON.parse(fetchFn.mock.calls[0][1].body); + expect(body.variables.status).toBe('ACTIVE'); + }); + }); + // ─── Error scenarios ─────────────────────────────────── describe('error handling', () => { diff --git a/src/index.ts b/src/index.ts index 06806c8..f4a27d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -224,6 +224,47 @@ server.tool( }), ); +// ─── list_open_rfqs ────────────────────────────────────────── + +server.tool( + 'list_open_rfqs', + [ + 'List currently open (ACTIVE) RFQs awaiting market-maker quotes. Read-only.', + '', + 'USE WHEN: acting as a market-maker agent deciding what to quote on, or showing the user live demand. DO NOT USE WHEN: you want your own trade history (use list_my_trades).', + '', + 'Returns a page of RFQs (id, baseToken, quoteToken, side, amount, isBlind, status, expiresAt). To quote, call respond_rfq with the rfqId.', + ].join('\n'), + { + page: z.number().optional().describe('1-based page number. Default 1.'), + pageSize: z.number().optional().describe('Page size, 1-100. Default 20.'), + }, + wrapTool(async ({ page, pageSize }) => okContent( + await hl.listRFQs({ status: 'ACTIVE', page: page ?? 1, pageSize: pageSize ?? 20 }), + )), +); + +// ─── list_my_trades ────────────────────────────────────────── + +server.tool( + 'list_my_trades', + [ + 'List the caller\'s trades (active + historical). Read-only. Primary tool for rebuilding state after losing conversation context.', + '', + 'USE WHEN: an agent restarted/lost context and must resync in-flight settlements, or showing the user their trade history. DO NOT USE WHEN: you need open market demand (use list_open_rfqs) or per-leg HTLC detail for one trade (use get_htlc).', + '', + 'Optional status filter narrows the page. For settlement-leg detail on a specific trade, follow up with get_htlc(tradeId).', + ].join('\n'), + { + status: z.string().optional().describe('Optional trade-status filter (e.g. ACTIVE, COMPLETED). Omit for all.'), + page: z.number().optional().describe('1-based page number. Default 1.'), + pageSize: z.number().optional().describe('Page size, 1-100. Default 20.'), + }, + wrapTool(async ({ status, page, pageSize }) => okContent( + await hl.listTrades({ status, page: page ?? 1, pageSize: pageSize ?? 20 } as Parameters[0]), + )), +); + // ─── Start server ──────────────────────────────────────────── async function main() { From 76e3019ee8df51a829c79a2528cb878f379d9797 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 19:05:26 +0300 Subject: [PATCH 08/14] feat(descriptions): homogenize create_htlc/withdraw_htlc/refund_htlc/respond_rfq to template + pin tests Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/tools.test.ts | 30 ++++++++++++++++++++++++++++++ src/index.ts | 36 ++++++++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index 1482120..e9048cc 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -468,6 +468,36 @@ describe('MCP Tool → SDK Integration', () => { }); }); + describe('homogenized tool descriptions carry routing markers', () => { + let source: string; + beforeEach(async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const url = await import('node:url'); + const here = path.dirname(url.fileURLToPath(import.meta.url)); + source = await fs.readFile(path.resolve(here, '..', 'index.ts'), 'utf8'); + }); + + for (const tool of ['create_htlc', 'withdraw_htlc', 'refund_htlc', 'get_htlc', 'respond_rfq']) { + it(`${tool} description has USE WHEN and DO NOT USE WHEN`, () => { + expect(source).toContain('USE WHEN'); + expect(source).toContain('DO NOT USE WHEN'); + }); + } + + it('every non-create_rfq tool description block contains both markers', () => { + const useWhen = (source.match(/USE WHEN:/g) ?? []).length; + const dontUse = (source.match(/DO NOT USE WHEN:/g) ?? []).length; + expect(useWhen).toBeGreaterThanOrEqual(6); + expect(dontUse).toBeGreaterThanOrEqual(6); + }); + + it('does not weaken the create_rfq intent compiler', () => { + expect(source).toContain('INTENT → PARAMS MAPPING'); + expect(source).toMatch(/RESTATE/); + }); + }); + // ─── Error scenarios ─────────────────────────────────── describe('error handling', () => { diff --git a/src/index.ts b/src/index.ts index f4a27d5..ef6740a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,14 @@ const server = new McpServer({ server.tool( 'create_htlc', - 'Trustless atomic settlement — delivery vs payment (DVP) guarantee. Both sides receive their asset OR both get refunded. Zero counterparty risk, zero slippage, no custodian. Record an on-chain HTLC lock tx hash for atomic OTC settlement. USE WHEN: a trade is accepted and the user has just broadcast the lock transaction on-chain (EVM, Bitcoin, or Sui). DO NOT USE WHEN: trade not yet accepted, or lock tx not yet confirmed on-chain. Chain-aware via chainType param (evm/bitcoin/sui). Cross-chain native: ETH↔BTC, ETH↔SUI, any supported pair.', + [ + 'Trustless atomic settlement — delivery vs payment (DVP) guarantee. Both sides receive their asset OR both get refunded; zero counterparty risk, zero slippage, no custodian. Records the on-chain HTLC lock tx hash to advance the settlement state machine.', + '', + 'USE WHEN: a trade is accepted and the user has just broadcast the lock transaction on-chain (EVM, Bitcoin, or Sui).', + 'DO NOT USE WHEN: the trade is not yet accepted, or the lock tx has not been broadcast yet — submit the on-chain tx first, then call this tool.', + '', + 'PARAM NOTES: `role` must be INITIATOR (you locked first) or COUNTERPARTY (you locked in response). `txHash` must be 0x-prefixed. `chainType` defaults to evm — set "bitcoin" or "sui" for non-EVM legs.', + ].join('\n'), { tradeId: z.string().describe('Trade ID from an accepted trade'), txHash: z.string().describe('On-chain transaction hash of the HTLC lock (0x-prefixed)'), @@ -54,7 +61,14 @@ server.tool( server.tool( 'withdraw_htlc', - 'Atomic claim — reveals preimage to unlock both legs simultaneously. Trustless cross-chain finality with zero counterparty risk. Claim an HTLC by revealing the 32-byte preimage — atomically unlocks the other leg of the swap. USE WHEN: counterparty has locked their side and the user wants to claim. DO NOT USE WHEN: counterparty lock not confirmed yet OR timelock has expired (use refund_htlc instead). Non-custodial: no intermediary holds funds at any point.', + [ + 'Atomic claim — reveals the 32-byte preimage to unlock both legs of the swap simultaneously. Trustless cross-chain finality: no intermediary holds funds at any point.', + '', + 'USE WHEN: counterparty has confirmed their lock on-chain and the user wants to claim their side of the swap.', + 'DO NOT USE WHEN: counterparty lock is not yet confirmed on-chain, OR the timelock has already expired — use refund_htlc instead.', + '', + 'PARAM NOTES: `preimage` must be 0x-prefixed 32-byte hex. Revealing the preimage is what makes the swap atomic — it simultaneously unlocks the counterparty leg. Set `chainType` to "bitcoin" or "sui" for non-EVM legs.', + ].join('\n'), { tradeId: z.string().describe('Trade ID'), txHash: z.string().describe('On-chain claim transaction hash (0x-prefixed)'), @@ -71,7 +85,14 @@ server.tool( server.tool( 'refund_htlc', - 'Trustless unwind — recover locked funds after timelock expiry with zero counterparty risk. Non-custodial refund guarantee: if the trade does not complete, funds return automatically. USE WHEN: counterparty never locked their side AND the timelock has passed. DO NOT USE WHEN: counterparty HAS locked and the swap can still complete (use withdraw_htlc). Only the original sender can refund, only post-deadline.', + [ + 'Trustless unwind — recover locked funds after the HTLC timelock expires. Non-custodial refund guarantee: if the swap does not complete, the original sender reclaims their asset with zero counterparty risk.', + '', + 'USE WHEN: the timelock deadline has passed AND the counterparty never locked their side (or the swap otherwise failed to complete).', + 'DO NOT USE WHEN: counterparty HAS locked and the swap can still complete — use withdraw_htlc instead. Only the original lock sender can call refund, and only after the deadline.', + '', + 'PARAM NOTES: `txHash` is the on-chain refund tx hash (0x-prefixed). No preimage needed — expiry alone unlocks the refund path. Set `chainType` to "bitcoin" or "sui" for non-EVM legs.', + ].join('\n'), { tradeId: z.string().describe('Trade ID'), txHash: z.string().describe('On-chain refund transaction hash (0x-prefixed)'), @@ -211,7 +232,14 @@ server.tool( server.tool( 'respond_rfq', - 'Market maker tool — submit sealed-bid quotes to compete on price. Private from other makers, no information leakage. Submit a sealed-bid price quote to an open RFQ (market-maker side). USE WHEN: the MCP client is acting as a market maker and has decided to quote on an open RFQ. DO NOT USE WHEN: acting as an end-user buyer/seller — use create_rfq to request quotes instead. Non-custodial: no funds locked until trade accepted. Agent-friendly: autonomous market-making via MCP.', + [ + 'Market-maker tool — submit a sealed-bid price quote to compete on an open RFQ. Quotes are private: other makers cannot see your price, and losing bids are never revealed. No funds are locked until the requester accepts a quote.', + '', + 'USE WHEN: the MCP client is acting as a market maker and has decided to quote on a specific open RFQ (obtained via list_open_rfqs).', + 'DO NOT USE WHEN: acting as an end-user buyer or seller who wants to receive quotes — use create_rfq instead. This is the market-maker side only; sealed bids, not open negotiation.', + '', + 'PARAM NOTES: `price` is per unit of base token in quote-token terms (e.g. "3450.00" for ETH priced in USDT). `amount` is base-token amount offered. No funds are locked at quote time — settlement only begins when the requester accepts.', + ].join('\n'), { rfqId: z.string().describe('ID of the RFQ to respond to'), price: z.string().describe('Price per unit of base token in quote token terms (e.g., "3450.00")'), From b3ed9a9666b217c7753c20f21f31d9ab91a4cf0a Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 19:29:27 +0300 Subject: [PATCH 09/14] test(descriptions): strengthen homogenized pin block to per-tool scoped assertions Old block only checked file-wide substring presence (any single USE WHEN passed all per-tool tests; >=6 floor pre-satisfied), so it could not catch a per-tool description regression. Replace with per-tool assertions anchored to text unique to each description, mirroring the create_rfq pin bar. Mutation-verified: removing a tool's DO NOT USE WHEN line now fails that tool's test. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/tools.test.ts | 54 +++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index e9048cc..913a584 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -469,7 +469,12 @@ describe('MCP Tool → SDK Integration', () => { }); describe('homogenized tool descriptions carry routing markers', () => { + // Pin the load-bearing routing markers so a future copy-edit cannot silently + // strip the USE WHEN / DO NOT USE WHEN lines that LLM tool-routers depend on. + // Each it() asserts text that is UNIQUE to that tool's description — not just + // present anywhere in the file — so a per-tool regression is caught individually. let source: string; + beforeEach(async () => { const fs = await import('node:fs/promises'); const path = await import('node:path'); @@ -478,23 +483,50 @@ describe('MCP Tool → SDK Integration', () => { source = await fs.readFile(path.resolve(here, '..', 'index.ts'), 'utf8'); }); - for (const tool of ['create_htlc', 'withdraw_htlc', 'refund_htlc', 'get_htlc', 'respond_rfq']) { - it(`${tool} description has USE WHEN and DO NOT USE WHEN`, () => { - expect(source).toContain('USE WHEN'); - expect(source).toContain('DO NOT USE WHEN'); - }); - } + it('create_htlc description: USE WHEN names the on-chain broadcast precondition', () => { + expect(source).toMatch(/USE WHEN:.*broadcast.*lock transaction/); + }); + + it('create_htlc description: DO NOT USE WHEN names the not-yet-accepted guard', () => { + expect(source).toMatch(/DO NOT USE WHEN:.*trade is not yet accepted/); + }); + + it('create_htlc description: PARAM NOTES declares role values INITIATOR and COUNTERPARTY', () => { + expect(source).toMatch(/PARAM NOTES:.*INITIATOR.*COUNTERPARTY/); + }); + + it('withdraw_htlc description: DO NOT USE WHEN names refund_htlc as the alternative', () => { + expect(source).toMatch(/DO NOT USE WHEN:.*timelock.*expired.*refund_htlc/); + }); + + it('withdraw_htlc description: PARAM NOTES explains the atomicity mechanism via preimage', () => { + expect(source).toContain('simultaneously unlocks the counterparty leg'); + }); + + it('refund_htlc description: DO NOT USE WHEN names withdraw_htlc as the alternative', () => { + expect(source).toMatch(/DO NOT USE WHEN:.*HAS locked.*withdraw_htlc/); + }); + + it('refund_htlc description: PARAM NOTES states no preimage needed', () => { + expect(source).toContain('No preimage needed'); + }); + + it('respond_rfq description: USE WHEN names list_open_rfqs as the discovery step', () => { + expect(source).toMatch(/USE WHEN:.*market maker.*list_open_rfqs/); + }); + + it('respond_rfq description: DO NOT USE WHEN names create_rfq as the buyer-side alternative', () => { + expect(source).toMatch(/DO NOT USE WHEN:.*buyer.*seller.*create_rfq/); + }); - it('every non-create_rfq tool description block contains both markers', () => { - const useWhen = (source.match(/USE WHEN:/g) ?? []).length; - const dontUse = (source.match(/DO NOT USE WHEN:/g) ?? []).length; - expect(useWhen).toBeGreaterThanOrEqual(6); - expect(dontUse).toBeGreaterThanOrEqual(6); + it('respond_rfq description: PARAM NOTES explains price semantics', () => { + expect(source).toContain('per unit of base token in quote-token terms'); }); it('does not weaken the create_rfq intent compiler', () => { expect(source).toContain('INTENT → PARAMS MAPPING'); expect(source).toMatch(/RESTATE/); + expect(source).toMatch(/Real funds/); }); }); From b058f2bd892cef7a34eff5c21d0ed1c29500062c Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 19:36:41 +0300 Subject: [PATCH 10/14] feat(idempotency): in-process client_request_id guard on write tools Best-effort, same-process scope; prevents context-loss retries from double-triggering writes. Durable dedupe tracked as a backend issue. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/idempotency.test.ts | 40 +++++++++++++++++++++++++++++++ src/index.ts | 33 +++++++++++++++++-------- src/lib/idempotency.ts | 24 +++++++++++++++++++ 3 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/idempotency.test.ts create mode 100644 src/lib/idempotency.ts diff --git a/src/__tests__/idempotency.test.ts b/src/__tests__/idempotency.test.ts new file mode 100644 index 0000000..5d287e7 --- /dev/null +++ b/src/__tests__/idempotency.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createIdempotencyGuard } from '../lib/idempotency.js'; + +describe('createIdempotencyGuard', () => { + it('runs the op once per key and replays the cached result', async () => { + const guard = createIdempotencyGuard(); + const op = vi.fn().mockResolvedValue({ ok: 1 }); + const a = await guard.remember('k1', op); + const b = await guard.remember('k1', op); + expect(op).toHaveBeenCalledTimes(1); + expect(b).toEqual(a); + }); + + it('runs the op for each distinct key', async () => { + const guard = createIdempotencyGuard(); + const op = vi.fn().mockResolvedValue({ ok: 1 }); + await guard.remember('k1', op); + await guard.remember('k2', op); + expect(op).toHaveBeenCalledTimes(2); + }); + + it('does not cache a rejected op (a failed write may be safely retried)', async () => { + const guard = createIdempotencyGuard(); + const op = vi.fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue({ ok: 2 }); + await expect(guard.remember('k1', op)).rejects.toThrow('boom'); + const second = await guard.remember('k1', op); + expect(second).toEqual({ ok: 2 }); + expect(op).toHaveBeenCalledTimes(2); + }); + + it('without a key, always runs (no dedupe)', async () => { + const guard = createIdempotencyGuard(); + const op = vi.fn().mockResolvedValue({ ok: 1 }); + await guard.remember(undefined, op); + await guard.remember(undefined, op); + expect(op).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/index.ts b/src/index.ts index ef6740a..28ee1c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { HashLock } from '@hashlock-tech/sdk'; import { okContent } from './lib/result.js'; import { wrapTool } from './lib/errors.js'; import { SUPPORTED_PAIRS } from './lib/pairs.js'; +import { createIdempotencyGuard } from './lib/idempotency.js'; // Default to the direct api-gateway endpoint (/graphql), NOT the browser-only // SSR proxy at /api/graphql. The SSR proxy reads the httpOnly `api-token` @@ -25,6 +26,8 @@ const hl = new HashLock({ timeout: 30_000, }); +const idempotency = createIdempotencyGuard(); + const server = new McpServer({ name: 'hashlock', version: '0.1.12', @@ -50,9 +53,11 @@ server.tool( hashlock: z.string().optional().describe('SHA-256 hashlock (0x-prefixed hex)'), chainType: z.string().optional().describe('Chain type: evm, bitcoin, or sui'), preimage: z.string().optional().describe('Secret preimage (only for initiator)'), + client_request_id: z.string().optional().describe('Idempotency key. Retrying the SAME write with the SAME id within this MCP session returns the first result instead of triggering a second on-chain/backend side effect. Best-effort: not durable across MCP restarts.'), }, - wrapTool(async ({ tradeId, txHash, role, timelock, hashlock, chainType, preimage }) => { - const result = await hl.fundHTLC({ tradeId, txHash, role, timelock, hashlock, chainType, preimage }); + wrapTool(async ({ tradeId, txHash, role, timelock, hashlock, chainType, preimage, client_request_id }) => { + const result = await idempotency.remember(client_request_id, () => + hl.fundHTLC({ tradeId, txHash, role, timelock, hashlock, chainType, preimage })); return okContent(result); }), ); @@ -74,9 +79,11 @@ server.tool( txHash: z.string().describe('On-chain claim transaction hash (0x-prefixed)'), preimage: z.string().describe('The 32-byte secret preimage (0x-prefixed hex)'), chainType: z.string().optional().describe('Chain type: evm, bitcoin, or sui'), + client_request_id: z.string().optional().describe('Idempotency key. Retrying the SAME write with the SAME id within this MCP session returns the first result instead of triggering a second on-chain/backend side effect. Best-effort: not durable across MCP restarts.'), }, - wrapTool(async ({ tradeId, txHash, preimage, chainType }) => { - const result = await hl.claimHTLC({ tradeId, txHash, preimage, chainType }); + wrapTool(async ({ tradeId, txHash, preimage, chainType, client_request_id }) => { + const result = await idempotency.remember(client_request_id, () => + hl.claimHTLC({ tradeId, txHash, preimage, chainType })); return okContent(result); }), ); @@ -97,9 +104,11 @@ server.tool( tradeId: z.string().describe('Trade ID'), txHash: z.string().describe('On-chain refund transaction hash (0x-prefixed)'), chainType: z.string().optional().describe('Chain type: evm, bitcoin, or sui'), + client_request_id: z.string().optional().describe('Idempotency key. Retrying the SAME write with the SAME id within this MCP session returns the first result instead of triggering a second on-chain/backend side effect. Best-effort: not durable across MCP restarts.'), }, - wrapTool(async ({ tradeId, txHash, chainType }) => { - const result = await hl.refundHTLC({ tradeId, txHash, chainType }); + wrapTool(async ({ tradeId, txHash, chainType, client_request_id }) => { + const result = await idempotency.remember(client_request_id, () => + hl.refundHTLC({ tradeId, txHash, chainType })); return okContent(result); }), ); @@ -201,13 +210,15 @@ server.tool( amount: z.string().describe('Amount of base token as a raw decimal string ("0.1", "1.5", "10"). Do NOT convert to wei/satoshis. Reject USD-denominated values — ask user for base-token amount instead.'), expiresIn: z.number().optional().describe('RFQ expiration in seconds. Default 300 (5 min). "Urgent" → 60-120. "Take your time" → 600-1800. Hard cap 86400 (24 h).'), isBlind: z.boolean().optional().describe('Ghost Auction mode — hides requester identity from bidders and losing counterparties. Default false. Set true on intent words: "ghost", "blind", "anonymous", "hide identity", "gizli". External brand: "Ghost Auction"; internal name retained for API/DB schema stability.'), + client_request_id: z.string().optional().describe('Idempotency key. Retrying the SAME write with the SAME id within this MCP session returns the first result instead of triggering a second on-chain/backend side effect. Best-effort: not durable across MCP restarts.'), }, - wrapTool(async ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind }) => { + wrapTool(async ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind, client_request_id }) => { // TODO: SDK type def (CreateRFQInput) lags backend — baseChain/quoteChain // are accepted by the GraphQL `createRFQ` mutation but not yet typed in // @hashlock-tech/sdk@0.2.0. Cast to bypass DTS build; remove once SDK // bumps the input type. Tracked separately from the v2 positioning sweep. - const result = await hl.createRFQ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind } as Parameters[0]); + const result = await idempotency.remember(client_request_id, () => + hl.createRFQ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind } as Parameters[0])); return okContent(result); }), ); @@ -245,9 +256,11 @@ server.tool( price: z.string().describe('Price per unit of base token in quote token terms (e.g., "3450.00")'), amount: z.string().describe('Amount of base token to offer'), expiresIn: z.number().optional().describe('Quote expiration in seconds'), + client_request_id: z.string().optional().describe('Idempotency key. Retrying the SAME write with the SAME id within this MCP session returns the first result instead of triggering a second on-chain/backend side effect. Best-effort: not durable across MCP restarts.'), }, - wrapTool(async ({ rfqId, price, amount, expiresIn }) => { - const result = await hl.submitQuote({ rfqId, price, amount, expiresIn }); + wrapTool(async ({ rfqId, price, amount, expiresIn, client_request_id }) => { + const result = await idempotency.remember(client_request_id, () => + hl.submitQuote({ rfqId, price, amount, expiresIn })); return okContent(result); }), ); diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts new file mode 100644 index 0000000..900ac2b --- /dev/null +++ b/src/lib/idempotency.ts @@ -0,0 +1,24 @@ +/** + * In-process best-effort idempotency. Scope-limited by design: + * - same MCP process only (not durable across restart, not cross-instance) + * - solves the dominant failure mode: an agent loses context and retries + * the SAME write within one session. + * Durable/cross-instance dedupe needs a backend key (out of scope; tracked + * as a backend issue). + */ +export interface IdempotencyGuard { + remember(key: string | undefined, op: () => Promise): Promise; +} + +export function createIdempotencyGuard(): IdempotencyGuard { + const cache = new Map(); + return { + async remember(key: string | undefined, op: () => Promise): Promise { + if (!key) return op(); + if (cache.has(key)) return cache.get(key) as T; + const result = await op(); // only cache on success — a thrown write is retryable + cache.set(key, result); + return result; + }, + }; +} From f139fe81a55bed7702106a2a28a1b088249f6e64 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 19:44:43 +0300 Subject: [PATCH 11/14] fix(idempotency): dedup in-flight concurrent same-key writes Cached the resolved value only, so two concurrent same client_request_id calls both missed the cache and both executed the write (MCP does not serialize tool requests). Cache the in-flight Promise and evict on rejection so op runs exactly once per key under concurrency while keeping failed writes retryable. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/idempotency.test.ts | 31 +++++++++++++++++++++++++++++++ src/lib/idempotency.ts | 14 +++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/__tests__/idempotency.test.ts b/src/__tests__/idempotency.test.ts index 5d287e7..8ddae9c 100644 --- a/src/__tests__/idempotency.test.ts +++ b/src/__tests__/idempotency.test.ts @@ -37,4 +37,35 @@ describe('createIdempotencyGuard', () => { await guard.remember(undefined, op); expect(op).toHaveBeenCalledTimes(2); }); + + it('concurrent same-key calls run op exactly once', async () => { + const guard = createIdempotencyGuard(); + let calls = 0; + const op = vi.fn(async () => { + calls++; + await new Promise((r) => setTimeout(r, 10)); + return { ok: calls }; + }); + const [a, b] = await Promise.all([ + guard.remember('k1', op), + guard.remember('k1', op), + ]); + expect(op).toHaveBeenCalledTimes(1); + expect(a).toEqual(b); + }); + + it('failed concurrent calls evict the cache so retries can proceed', async () => { + const guard = createIdempotencyGuard(); + const op = vi.fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue({ ok: 2 }); + await expect(Promise.all([ + guard.remember('k1', op), + guard.remember('k1', op), + ])).rejects.toThrow('boom'); + expect(op).toHaveBeenCalledTimes(1); + const result = await guard.remember('k1', op); + expect(result).toEqual({ ok: 2 }); + expect(op).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 900ac2b..8c267a9 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -11,14 +11,18 @@ export interface IdempotencyGuard { } export function createIdempotencyGuard(): IdempotencyGuard { - const cache = new Map(); + const cache = new Map>(); return { async remember(key: string | undefined, op: () => Promise): Promise { if (!key) return op(); - if (cache.has(key)) return cache.get(key) as T; - const result = await op(); // only cache on success — a thrown write is retryable - cache.set(key, result); - return result; + const existing = cache.get(key); + if (existing) return existing as Promise; + const promise = op().catch((err) => { + cache.delete(key); // failed write is retryable — drop so a retry can proceed + return Promise.reject(err); + }); + cache.set(key, promise); + return promise as Promise; }, }; } From 0d278542fa4a69cbd4765ca298ab87c2710a19e1 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 20:08:27 +0300 Subject: [PATCH 12/14] fix: sync MCP server version to 0.2.0; enforce page/pageSize bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holistic-review pre-publish fixes: (1) McpServer announced stale 0.1.12 while package.json is 0.2.0 — every client/registry would see the wrong version. (2) list_open_rfqs/list_my_trades page/pageSize descriptions promised 1-100 / 1-based but zod was z.number().optional() — tighten to .int().min(1).max(100) so an out-of-range arg fails fast with a clear schema error instead of an opaque downstream one. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 28ee1c0..5b87474 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,7 @@ const idempotency = createIdempotencyGuard(); const server = new McpServer({ name: 'hashlock', - version: '0.1.12', + version: '0.2.0', }); // ─── create_htlc ───────────────────────────────────────────── @@ -277,8 +277,8 @@ server.tool( 'Returns a page of RFQs (id, baseToken, quoteToken, side, amount, isBlind, status, expiresAt). To quote, call respond_rfq with the rfqId.', ].join('\n'), { - page: z.number().optional().describe('1-based page number. Default 1.'), - pageSize: z.number().optional().describe('Page size, 1-100. Default 20.'), + page: z.number().int().min(1).optional().describe('1-based page number. Default 1.'), + pageSize: z.number().int().min(1).max(100).optional().describe('Page size, 1-100. Default 20.'), }, wrapTool(async ({ page, pageSize }) => okContent( await hl.listRFQs({ status: 'ACTIVE', page: page ?? 1, pageSize: pageSize ?? 20 }), @@ -298,8 +298,8 @@ server.tool( ].join('\n'), { status: z.string().optional().describe('Optional trade-status filter (e.g. ACTIVE, COMPLETED). Omit for all.'), - page: z.number().optional().describe('1-based page number. Default 1.'), - pageSize: z.number().optional().describe('Page size, 1-100. Default 20.'), + page: z.number().int().min(1).optional().describe('1-based page number. Default 1.'), + pageSize: z.number().int().min(1).max(100).optional().describe('Page size, 1-100. Default 20.'), }, wrapTool(async ({ status, page, pageSize }) => okContent( await hl.listTrades({ status, page: page ?? 1, pageSize: pageSize ?? 20 } as Parameters[0]), From 5d7df3b65c29e1c222030ca5e57195084f511750 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 21:02:41 +0300 Subject: [PATCH 13/14] fix: green CI (pin sdk ^0.1.4 + lockfile) + address CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: package.json had @hashlock-tech/sdk ^0.2.0 (v0.2.0 sweep) but lockfile + all tests/build ran against 0.1.x — pin to ^0.1.4 (the verified version) and regenerate lockfile; SDK 0.2.0 upgrade is a separate tested change. CodeRabbit: server.json version 0.1.12→0.2.0 (registry-publish blocker); errors.ts preserves .message for non-Error throwables (was [object Object]); idempotency key now scoped by tool+payload so a reused client_request_id can't replay an unrelated result; bounded the idempotency Map (FIFO cap 1000) to prevent unbounded growth. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 +- package.json | 2 +- pnpm-lock.yaml | 10 ++-- server.json | 2 +- src/__tests__/errors.test.ts | 15 +++++ src/__tests__/idempotency.test.ts | 92 ++++++++++++++++++++++++++++++- src/index.ts | 29 ++++++---- src/lib/errors.ts | 13 ++++- src/lib/idempotency.ts | 15 +++++ 9 files changed, 158 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7dd2855..7430c52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hashlock-tech/mcp", - "version": "0.1.8", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hashlock-tech/mcp", - "version": "0.1.8", + "version": "0.2.0", "license": "MIT", "dependencies": { "@hashlock-tech/sdk": "^0.1.4", diff --git a/package.json b/package.json index d89e282..00b185d 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@hashlock-tech/sdk": "^0.2.0", + "@hashlock-tech/sdk": "^0.1.4", "@modelcontextprotocol/sdk": "^1.12.1", "zod": "^3.23.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20c2720..f26797b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@hashlock-tech/sdk': - specifier: ^0.1.3 - version: 0.1.3 + specifier: ^0.1.4 + version: 0.1.4 '@modelcontextprotocol/sdk': specifier: ^1.12.1 version: 1.29.0(zod@3.25.76) @@ -198,8 +198,8 @@ packages: cpu: [x64] os: [win32] - '@hashlock-tech/sdk@0.1.3': - resolution: {integrity: sha512-kDiTJ4G0DPE75KWWF8lm5N3xASmHSCKQWY5u6MUYe6S4JwC2Y0no8TF6bRxHYKWnIX8okS99CMtnm0kHJ/x4TA==} + '@hashlock-tech/sdk@0.1.4': + resolution: {integrity: sha512-PTKWfmdpcTK9wuMJVwUPQLLxq8DvVMrAGdTtjmTkyOxZJIybLFD96sPrSo2KdUSKUIT4LJCS7hTEpncuh9gnnA==} engines: {node: '>=18'} '@hono/node-server@1.19.13': @@ -1365,7 +1365,7 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@hashlock-tech/sdk@0.1.3': {} + '@hashlock-tech/sdk@0.1.4': {} '@hono/node-server@1.19.13(hono@4.12.12)': dependencies: diff --git a/server.json b/server.json index 8cbddda..1be41ba 100644 --- a/server.json +++ b/server.json @@ -1,7 +1,7 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", "name": "io.github.Hashlock-Tech/hashlock", - "version": "0.1.12", + "version": "0.2.0", "description": "Trustless cross-chain OTC settlement for AI agents. Sealed-bid RFQ + HTLC atomic swap.", "vendor": "Hashlock-Tech", "sourceUrl": "https://github.com/Hashlock-Tech/hashlock-mcp", diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts index 1570e9a..346f4d5 100644 --- a/src/__tests__/errors.test.ts +++ b/src/__tests__/errors.test.ts @@ -84,3 +84,18 @@ describe('wrapTool', () => { expect(payload.error.code).toBe('UNKNOWN'); }); }); + +describe('non-Error object throwables', () => { + it('classifyError extracts .message from a plain object (not [object Object])', () => { + const c = classifyError({ message: 'No trade found for tradeId q' }); + expect(c.code).toBe('TRADE_NOT_FOUND'); + }); + + it('wrapTool preserves .message from a thrown plain object', async () => { + const wrapped = wrapTool(async () => { throw { message: 'custom object failure' }; }); + const out = await wrapped(); + const payload = JSON.parse(out.content[0].text); + expect(payload.error.message).toBe('custom object failure'); + expect(payload.error.code).toBe('UNKNOWN'); + }); +}); diff --git a/src/__tests__/idempotency.test.ts b/src/__tests__/idempotency.test.ts index 8ddae9c..538c0e3 100644 --- a/src/__tests__/idempotency.test.ts +++ b/src/__tests__/idempotency.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { createIdempotencyGuard } from '../lib/idempotency.js'; +import { createIdempotencyGuard, idempotencyKey } from '../lib/idempotency.js'; describe('createIdempotencyGuard', () => { it('runs the op once per key and replays the cached result', async () => { @@ -69,3 +69,93 @@ describe('createIdempotencyGuard', () => { expect(op).toHaveBeenCalledTimes(2); }); }); + +describe('createIdempotencyGuard — eviction cap', () => { + it('does not grow unbounded: the oldest entry is evicted once MAX_ENTRIES is exceeded', async () => { + const guard = createIdempotencyGuard(); + const MAX = 1000; + // Fill the cache to exactly MAX entries + for (let i = 0; i < MAX; i++) { + await guard.remember(`fill-${i}`, () => Promise.resolve(i)); + } + // The very first key should still be a cache hit (not yet evicted) + const firstOp = vi.fn().mockResolvedValue('first-again'); + await guard.remember('fill-0', firstOp); + expect(firstOp).not.toHaveBeenCalled(); // still cached + + // Insert one more — this should evict fill-0 (oldest) + await guard.remember('fill-overflow', () => Promise.resolve('overflow')); + + // Now fill-0 must have been evicted: op runs again + const evictedOp = vi.fn().mockResolvedValue('re-run'); + await guard.remember('fill-0', evictedOp); + expect(evictedOp).toHaveBeenCalledTimes(1); + + // A recent key (fill-999) should still be cached + const recentOp = vi.fn().mockResolvedValue('recent-again'); + await guard.remember('fill-999', recentOp); + expect(recentOp).not.toHaveBeenCalled(); + }); +}); + +describe('idempotencyKey helper', () => { + it('returns undefined when clientRequestId is undefined (no dedup)', () => { + expect(idempotencyKey('create_htlc', undefined, { tradeId: 't1' })).toBeUndefined(); + }); + + it('same scope+id+payload returns the same key (dedup)', () => { + const payload = { tradeId: 't1', amount: '1.0' }; + const k1 = idempotencyKey('create_rfq', 'req-1', payload); + const k2 = idempotencyKey('create_rfq', 'req-1', payload); + expect(k1).toBe(k2); + expect(k1).toBeDefined(); + }); + + it('same id but different scope returns a different key (no cross-tool replay)', () => { + const payload = { tradeId: 't1' }; + const k1 = idempotencyKey('create_htlc', 'req-1', payload); + const k2 = idempotencyKey('withdraw_htlc', 'req-1', payload); + expect(k1).not.toBe(k2); + }); + + it('same scope+id but different payload returns a different key (no wrong-payload replay)', () => { + const k1 = idempotencyKey('create_rfq', 'req-1', { amount: '1.0' }); + const k2 = idempotencyKey('create_rfq', 'req-1', { amount: '2.0' }); + expect(k1).not.toBe(k2); + }); + + it('guard deduplicates correctly when using idempotencyKey (same scope+id+payload = op once)', async () => { + const guard = createIdempotencyGuard(); + const op = vi.fn().mockResolvedValue({ ok: 1 }); + const payload = { tradeId: 't1' }; + await guard.remember(idempotencyKey('create_htlc', 'req-1', payload), op); + await guard.remember(idempotencyKey('create_htlc', 'req-1', payload), op); + expect(op).toHaveBeenCalledTimes(1); + }); + + it('guard runs op twice when scope differs even with same id+payload', async () => { + const guard = createIdempotencyGuard(); + const op = vi.fn().mockResolvedValue({ ok: 1 }); + const payload = { tradeId: 't1' }; + await guard.remember(idempotencyKey('create_htlc', 'req-1', payload), op); + await guard.remember(idempotencyKey('withdraw_htlc', 'req-1', payload), op); + expect(op).toHaveBeenCalledTimes(2); + }); + + it('guard runs op twice when payload differs even with same scope+id', async () => { + const guard = createIdempotencyGuard(); + const op = vi.fn().mockResolvedValue({ ok: 1 }); + await guard.remember(idempotencyKey('create_rfq', 'req-1', { amount: '1.0' }), op); + await guard.remember(idempotencyKey('create_rfq', 'req-1', { amount: '2.0' }), op); + expect(op).toHaveBeenCalledTimes(2); + }); + + it('guard runs op every time when id is undefined (no dedup)', async () => { + const guard = createIdempotencyGuard(); + const op = vi.fn().mockResolvedValue({ ok: 1 }); + const payload = { tradeId: 't1' }; + await guard.remember(idempotencyKey('create_htlc', undefined, payload), op); + await guard.remember(idempotencyKey('create_htlc', undefined, payload), op); + expect(op).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/index.ts b/src/index.ts index 5b87474..49c6f57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { HashLock } from '@hashlock-tech/sdk'; import { okContent } from './lib/result.js'; import { wrapTool } from './lib/errors.js'; import { SUPPORTED_PAIRS } from './lib/pairs.js'; -import { createIdempotencyGuard } from './lib/idempotency.js'; +import { createIdempotencyGuard, idempotencyKey } from './lib/idempotency.js'; // Default to the direct api-gateway endpoint (/graphql), NOT the browser-only // SSR proxy at /api/graphql. The SSR proxy reads the httpOnly `api-token` @@ -56,8 +56,9 @@ server.tool( client_request_id: z.string().optional().describe('Idempotency key. Retrying the SAME write with the SAME id within this MCP session returns the first result instead of triggering a second on-chain/backend side effect. Best-effort: not durable across MCP restarts.'), }, wrapTool(async ({ tradeId, txHash, role, timelock, hashlock, chainType, preimage, client_request_id }) => { - const result = await idempotency.remember(client_request_id, () => - hl.fundHTLC({ tradeId, txHash, role, timelock, hashlock, chainType, preimage })); + const input = { tradeId, txHash, role, timelock, hashlock, chainType, preimage }; + const result = await idempotency.remember(idempotencyKey('create_htlc', client_request_id, input), () => + hl.fundHTLC(input)); return okContent(result); }), ); @@ -82,8 +83,9 @@ server.tool( client_request_id: z.string().optional().describe('Idempotency key. Retrying the SAME write with the SAME id within this MCP session returns the first result instead of triggering a second on-chain/backend side effect. Best-effort: not durable across MCP restarts.'), }, wrapTool(async ({ tradeId, txHash, preimage, chainType, client_request_id }) => { - const result = await idempotency.remember(client_request_id, () => - hl.claimHTLC({ tradeId, txHash, preimage, chainType })); + const input = { tradeId, txHash, preimage, chainType }; + const result = await idempotency.remember(idempotencyKey('withdraw_htlc', client_request_id, input), () => + hl.claimHTLC(input)); return okContent(result); }), ); @@ -107,8 +109,9 @@ server.tool( client_request_id: z.string().optional().describe('Idempotency key. Retrying the SAME write with the SAME id within this MCP session returns the first result instead of triggering a second on-chain/backend side effect. Best-effort: not durable across MCP restarts.'), }, wrapTool(async ({ tradeId, txHash, chainType, client_request_id }) => { - const result = await idempotency.remember(client_request_id, () => - hl.refundHTLC({ tradeId, txHash, chainType })); + const input = { tradeId, txHash, chainType }; + const result = await idempotency.remember(idempotencyKey('refund_htlc', client_request_id, input), () => + hl.refundHTLC(input)); return okContent(result); }), ); @@ -215,10 +218,11 @@ server.tool( wrapTool(async ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind, client_request_id }) => { // TODO: SDK type def (CreateRFQInput) lags backend — baseChain/quoteChain // are accepted by the GraphQL `createRFQ` mutation but not yet typed in - // @hashlock-tech/sdk@0.2.0. Cast to bypass DTS build; remove once SDK + // @hashlock-tech/sdk@0.1.4. Cast to bypass DTS build; remove once SDK // bumps the input type. Tracked separately from the v2 positioning sweep. - const result = await idempotency.remember(client_request_id, () => - hl.createRFQ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind } as Parameters[0])); + const input = { baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind } as Parameters[0]; + const result = await idempotency.remember(idempotencyKey('create_rfq', client_request_id, input), () => + hl.createRFQ(input)); return okContent(result); }), ); @@ -259,8 +263,9 @@ server.tool( client_request_id: z.string().optional().describe('Idempotency key. Retrying the SAME write with the SAME id within this MCP session returns the first result instead of triggering a second on-chain/backend side effect. Best-effort: not durable across MCP restarts.'), }, wrapTool(async ({ rfqId, price, amount, expiresIn, client_request_id }) => { - const result = await idempotency.remember(client_request_id, () => - hl.submitQuote({ rfqId, price, amount, expiresIn })); + const input = { rfqId, price, amount, expiresIn }; + const result = await idempotency.remember(idempotencyKey('respond_rfq', client_request_id, input), () => + hl.submitQuote(input)); return okContent(result); }), ); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index c46b890..18ad79a 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -28,8 +28,17 @@ const RULES: { test: RegExp; code: ErrorCode; is_retryable: boolean; recovery_hi recovery_hint: 'Fix the offending argument and retry. Check token/chain are in list_supported_pairs.' }, ]; +function extractMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (err && typeof err === 'object' && 'message' in err + && typeof (err as { message: unknown }).message === 'string') { + return (err as { message: string }).message; + } + return String(err); +} + export function classifyError(err: unknown): Classification { - const message = err instanceof Error ? err.message : String(err); + const message = extractMessage(err); for (const r of RULES) { if (r.test.test(message)) { return { code: r.code, is_retryable: r.is_retryable, recovery_hint: r.recovery_hint }; @@ -55,7 +64,7 @@ export function classifyError(err: unknown): Classification { * `result.ts` to inject it. */ export function toErrorEnvelope(err: unknown): ToolContent { - const message = err instanceof Error ? err.message : String(err); + const message = extractMessage(err); const c = classifyError(err); return okContent({ error: { diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 8c267a9..1ca72e4 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -10,6 +10,8 @@ export interface IdempotencyGuard { remember(key: string | undefined, op: () => Promise): Promise; } +const MAX_ENTRIES = 1000; + export function createIdempotencyGuard(): IdempotencyGuard { const cache = new Map>(); return { @@ -21,8 +23,21 @@ export function createIdempotencyGuard(): IdempotencyGuard { cache.delete(key); // failed write is retryable — drop so a retry can proceed return Promise.reject(err); }); + if (cache.size >= MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } cache.set(key, promise); return promise as Promise; }, }; } + +/** Compose a cache key scoped by operation + exact payload so the same + * client_request_id reused for a different tool or a different payload + * does NOT replay an unrelated result. Returns undefined when no id + * (=> no dedup, always run). */ +export function idempotencyKey(scope: string, clientRequestId: string | undefined, payload: unknown): string | undefined { + if (!clientRequestId) return undefined; + return `${scope}:${clientRequestId}:${JSON.stringify(payload)}`; +} From b00f1e2fe561aae7e9a7788dc7616a8cf115eda0 Mon Sep 17 00:00:00 2001 From: BarisSozen Date: Mon, 18 May 2026 21:13:12 +0300 Subject: [PATCH 14/14] docs(idempotency): note in-flight eviction semantics on the money-path guard Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/idempotency.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 1ca72e4..f2173ca 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -23,6 +23,10 @@ export function createIdempotencyGuard(): IdempotencyGuard { cache.delete(key); // failed write is retryable — drop so a retry can proceed return Promise.reject(err); }); + // Eviction drops the oldest key by insertion order. If that entry is + // still in-flight (pending), a re-entry for the same key will re-run + // the op — acceptable: this guard is in-process and best-effort, and + // 1000 simultaneous in-flight writes is not a realistic scenario here. if (cache.size >= MAX_ENTRIES) { const oldest = cache.keys().next().value; if (oldest !== undefined) cache.delete(oldest);