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-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 24b462d..00b185d 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": { @@ -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" }, @@ -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/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 new file mode 100644 index 0000000..1be41ba --- /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.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", + "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/__tests__/errors.test.ts b/src/__tests__/errors.test.ts new file mode 100644 index 0000000..346f4d5 --- /dev/null +++ b/src/__tests__/errors.test.ts @@ -0,0 +1,101 @@ +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); + }); + + // 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', () => { + 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'); + }); + + 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'); + }); +}); + +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 new file mode 100644 index 0000000..538c0e3 --- /dev/null +++ b/src/__tests__/idempotency.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createIdempotencyGuard, idempotencyKey } 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); + }); + + 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); + }); +}); + +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/__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/__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/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index 4c8a27b..913a584 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([]); }); }); @@ -434,6 +442,94 @@ 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'); + }); + }); + + 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'); + 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'); + }); + + 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('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/); + }); + }); + // ─── Error scenarios ─────────────────────────────────── describe('error handling', () => { diff --git a/src/index.ts b/src/index.ts index 91daa48..49c6f57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,10 @@ 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'; +import { wrapTool } from './lib/errors.js'; +import { SUPPORTED_PAIRS } from './lib/pairs.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` @@ -22,16 +26,25 @@ const hl = new HashLock({ timeout: 30_000, }); +const idempotency = createIdempotencyGuard(); + const server = new McpServer({ name: 'hashlock', - version: '0.1.12', + version: '0.2.0', }); // ─── create_htlc ───────────────────────────────────────────── 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)'), @@ -40,58 +53,90 @@ 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.'), }, - 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) }] }; - }, + wrapTool(async ({ tradeId, txHash, role, timelock, hashlock, chainType, preimage, client_request_id }) => { + 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); + }), ); // ─── withdraw_htlc ─────────────────────────────────────────── 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)'), 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.'), }, - async ({ tradeId, txHash, preimage, chainType }) => { - const result = await hl.claimHTLC({ tradeId, txHash, preimage, chainType }); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; - }, + wrapTool(async ({ tradeId, txHash, preimage, chainType, client_request_id }) => { + const input = { tradeId, txHash, preimage, chainType }; + const result = await idempotency.remember(idempotencyKey('withdraw_htlc', client_request_id, input), () => + hl.claimHTLC(input)); + return okContent(result); + }), ); // ─── refund_htlc ───────────────────────────────────────────── 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)'), 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.'), }, - async ({ tradeId, txHash, chainType }) => { - const result = await hl.refundHTLC({ tradeId, txHash, chainType }); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; - }, + wrapTool(async ({ tradeId, txHash, chainType, client_request_id }) => { + const input = { tradeId, txHash, chainType }; + const result = await idempotency.remember(idempotencyKey('refund_htlc', client_request_id, input), () => + hl.refundHTLC(input)); + return okContent(result); + }), ); // ─── get_htlc ──────────────────────────────────────────────── 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'), - }, - async ({ tradeId }) => { - const result = await hl.getHTLCStatus(tradeId); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + tradeId: z.string().describe('Trade ID to query HTLC legs for. An unknown ID returns an empty array, not an error.'), }, + wrapTool(async ({ tradeId }) => { + const result = await hl.getHTLCs(tradeId); + return okContent(result); + }), ); // ─── create_rfq ────────────────────────────────────────────── @@ -168,28 +213,102 @@ 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.'), }, - async ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind }) => { - const result = await hl.createRFQ({ baseToken, baseChain, quoteToken, quoteChain, side, amount, expiresIn, isBlind }); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; - }, + 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.1.4. Cast to bypass DTS build; remove once SDK + // bumps the input type. Tracked separately from the v2 positioning sweep. + 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); + }), +); + +// ─── 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( '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")'), 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.'), }, - async ({ rfqId, price, amount, expiresIn }) => { - const result = await hl.submitQuote({ rfqId, price, amount, expiresIn }); - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + wrapTool(async ({ rfqId, price, amount, expiresIn, client_request_id }) => { + const input = { rfqId, price, amount, expiresIn }; + const result = await idempotency.remember(idempotencyKey('respond_rfq', client_request_id, input), () => + hl.submitQuote(input)); + return okContent(result); + }), +); + +// ─── 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().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 }), + )), +); + +// ─── 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().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]), + )), ); // ─── Start server ──────────────────────────────────────────── diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..18ad79a --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,95 @@ +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: /(?: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.' }, + { 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.' }, +]; + +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 = 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 }; + } + } + return { + code: 'UNKNOWN', + is_retryable: false, + recovery_hint: 'Unrecognized failure. Inspect the message; do not blindly retry a write.', + }; +} + +/** + * 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 = extractMessage(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. + * 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 { + return async (...args: A) => { + try { + return await handler(...args); + } catch (err) { + return toErrorEnvelope(err); + } + }; +} diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts new file mode 100644 index 0000000..f2173ca --- /dev/null +++ b/src/lib/idempotency.ts @@ -0,0 +1,47 @@ +/** + * 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; +} + +const MAX_ENTRIES = 1000; + +export function createIdempotencyGuard(): IdempotencyGuard { + const cache = new Map>(); + return { + async remember(key: string | undefined, op: () => Promise): Promise { + if (!key) return op(); + 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); + }); + // 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); + } + 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)}`; +} 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(', ') + '.'; 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) }] }; +}