From c65f4209d05687f922c42ef4b2ce961661b0e7b0 Mon Sep 17 00:00:00 2001 From: Fernando Sobreira Date: Sun, 15 Feb 2026 09:53:11 -0500 Subject: [PATCH 1/7] feat: add on-chain tools for Klever blockchain interaction Add KleverChainClient (zero-dependency, native fetch) with 11 methods for querying the Klever blockchain. Expose 7 read-only MCP tools (get_balance, get_account, get_asset_info, query_sc, get_transaction, get_block, list_validators) available in all profiles, plus 4 write tools (send_transfer, deploy_sc, invoke_sc, freeze_klv) gated to local profile only. Includes 26 unit tests for the chain client and 14 integration tests for MCP tool handlers. --- CLAUDE.md | 8 + src/chain/client.test.ts | 407 +++++++++++++++++++ src/chain/client.ts | 334 +++++++++++++++ src/chain/index.ts | 19 + src/chain/types.ts | 243 +++++++++++ src/index.ts | 18 +- src/mcp/server.test.ts | 268 ++++++++++++ src/mcp/server.ts | 853 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 2140 insertions(+), 10 deletions(-) create mode 100644 src/chain/client.test.ts create mode 100644 src/chain/client.ts create mode 100644 src/chain/index.ts create mode 100644 src/chain/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7fc2478..d9ab3c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,10 @@ The MCP server (`src/mcp/server.ts`) exposes: `query_context`, `add_context`, `g 2. Update relevance scoring in `ContextService.calculateRelevanceScore()` 3. Update MCP tool input schemas in `src/mcp/server.ts` (the enum arrays in `query_context` and `add_context`) +### Chain Client + +`src/chain/` provides a zero-dependency HTTP client for querying the Klever blockchain (uses native `fetch`). `KleverChainClient` supports mainnet/testnet/devnet/local with per-call network override. The MCP server creates a chain client at startup (configured via env vars) and passes it to `KleverMCPServer`. On-chain tools (get_balance, get_account, get_asset_info, query_sc, get_transaction, get_block, list_validators) are available in all profiles. Write tools (send_transfer, deploy_sc, invoke_sc, freeze_klv) are local-only. + ## Environment Variables - `MODE`: `http` (default) or `mcp` @@ -69,6 +73,10 @@ The MCP server (`src/mcp/server.ts`) exposes: `query_context`, `add_context`, `g - `REDIS_URL`: Redis connection string (only for redis storage) - `MEMORY_MAX_SIZE`: Max contexts in memory storage (default: 10000) - `NODE_ENV`: `development` or `production` (affects error detail in responses) +- `KLEVER_NETWORK`: Default chain network: `mainnet` (default), `testnet`, `devnet`, `local` +- `KLEVER_NODE_URL`: Custom node URL (overrides network-based URL) +- `KLEVER_API_URL`: Custom API proxy URL (overrides network-based URL) +- `KLEVER_TIMEOUT`: Chain client request timeout in ms (default: 15000) ## Branching & Release Process diff --git a/src/chain/client.test.ts b/src/chain/client.test.ts new file mode 100644 index 0000000..de8ab8c --- /dev/null +++ b/src/chain/client.test.ts @@ -0,0 +1,407 @@ +import { jest } from '@jest/globals'; +import { KleverChainClient, NETWORK_CONFIGS } from './client.js'; + +// Mock global fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +function jsonResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + redirected: false, + type: 'basic' as ResponseType, + url: '', + clone: () => jsonResponse(data, status), + body: null, + bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + bytes: () => Promise.resolve(new Uint8Array()), + }; +} + +describe('KleverChainClient', () => { + let client: KleverChainClient; + + beforeEach(() => { + mockFetch.mockClear(); + client = new KleverChainClient({ network: 'testnet', timeout: 5000 }); + }); + + describe('constructor and configuration', () => { + it('uses default mainnet when no network specified', () => { + const defaultClient = new KleverChainClient(); + expect(defaultClient.getDefaultNetwork()).toBe('mainnet'); + expect(defaultClient.getNodeUrl()).toBe(NETWORK_CONFIGS.mainnet.nodeUrl); + expect(defaultClient.getApiUrl()).toBe(NETWORK_CONFIGS.mainnet.apiUrl); + }); + + it('uses specified network', () => { + expect(client.getDefaultNetwork()).toBe('testnet'); + expect(client.getNodeUrl()).toBe('https://node.testnet.klever.org'); + expect(client.getApiUrl()).toBe('https://api.testnet.klever.org'); + }); + + it('allows per-call network override', () => { + expect(client.getNodeUrl('devnet')).toBe('https://node.devnet.klever.org'); + expect(client.getApiUrl('devnet')).toBe('https://api.devnet.klever.org'); + }); + + it('uses custom URLs when provided', () => { + const customClient = new KleverChainClient({ + nodeUrl: 'http://custom-node:8080', + apiUrl: 'http://custom-api:9090', + }); + expect(customClient.getNodeUrl()).toBe('http://custom-node:8080'); + expect(customClient.getApiUrl()).toBe('http://custom-api:9090'); + // Custom URLs ignore network override + expect(customClient.getNodeUrl('devnet')).toBe('http://custom-node:8080'); + }); + + it('uses local network URLs', () => { + const localClient = new KleverChainClient({ network: 'local' }); + expect(localClient.getNodeUrl()).toBe('http://localhost:8080'); + expect(localClient.getApiUrl()).toBe('http://localhost:9090'); + }); + }); + + describe('getBalance', () => { + it('fetches KLV balance', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { balance: 5000000 }, error: '', code: 'successful' }) + ); + + const balance = await client.getBalance('klv1test'); + expect(balance).toBe(5000000); + expect(mockFetch).toHaveBeenCalledWith( + 'https://node.testnet.klever.org/address/klv1test/balance', + expect.objectContaining({ headers: { Accept: 'application/json' } }) + ); + }); + + it('fetches KDA token balance', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { balance: 99999000 }, error: '', code: 'successful' }) + ); + + const balance = await client.getBalance('klv1test', 'LPKLVKFI-3I0N'); + expect(balance).toBe(99999000); + expect(mockFetch).toHaveBeenCalledWith( + 'https://node.testnet.klever.org/address/klv1test/balance?asset=LPKLVKFI-3I0N', + expect.anything() + ); + }); + + it('uses network override', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { balance: 100 }, error: '', code: 'successful' }) + ); + + await client.getBalance('klv1test', undefined, 'mainnet'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://node.mainnet.klever.org/address/klv1test/balance', + expect.anything() + ); + }); + + it('throws on API error response', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: null, error: 'address not found', code: 'internal_issue' }) + ); + + await expect(client.getBalance('klv1invalid')).rejects.toThrow('address not found'); + }); + + it('throws on HTTP error', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({}, 500)); + + await expect(client.getBalance('klv1test')).rejects.toThrow('HTTP 500'); + }); + }); + + describe('getAccount', () => { + it('fetches full account details', async () => { + const accountData = { + address: 'klv1test', + nonce: 42, + balance: 10000000, + frozenBalance: 5000000, + allowance: 0, + permissions: [], + timestamp: 1700000000, + }; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { account: accountData }, error: '', code: 'successful' }) + ); + + const account = await client.getAccount('klv1test'); + expect(account.address).toBe('klv1test'); + expect(account.nonce).toBe(42); + expect(account.balance).toBe(10000000); + }); + }); + + describe('getAssetInfo', () => { + it('fetches asset properties', async () => { + const assetData = { + ID: 'S0xW', + Name: 'S0xFVkVS', + Ticker: 'S0xW', + Precision: 6, + InitialSupply: 29000000000000, + CirculatingSupply: 28995438394794, + MintedValue: 29000000000000, + Properties: { CanMint: true, CanBurn: true, CanFreeze: true }, + }; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { asset: assetData }, error: '', code: 'successful' }) + ); + + const asset = await client.getAssetInfo('KLV'); + expect(asset.Precision).toBe(6); + expect(asset.Properties.CanMint).toBe(true); + }); + + it('URL-encodes the asset ID', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { asset: { ID: 'test', Precision: 6 } }, + error: '', + code: 'successful', + }) + ); + + await client.getAssetInfo('LPKLVKFI-3I0N'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://node.testnet.klever.org/asset/LPKLVKFI-3I0N', + expect.anything() + ); + }); + }); + + describe('querySmartContract', () => { + it('sends VM query and returns result', async () => { + const vmResult = { + returnData: ['AQID'], + returnCode: 'ok', + returnMessage: '', + }; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: vmResult, error: '', code: 'successful' }) + ); + + const result = await client.querySmartContract({ + scAddress: 'klv1contract', + funcName: 'getValue', + args: ['AQID'], + }); + + expect(result.returnData).toEqual(['AQID']); + expect(result.returnCode).toBe('ok'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://node.testnet.klever.org/vm/query', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + scAddress: 'klv1contract', + funcName: 'getValue', + args: ['AQID'], + }), + }) + ); + }); + }); + + describe('getTransaction', () => { + it('fetches transaction from API proxy', async () => { + const txData = { + hash: 'abc123', + sender: 'klv1sender', + status: 'success', + }; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { transaction: txData }, error: '' }) + ); + + const tx = await client.getTransaction('abc123'); + expect(tx.hash).toBe('abc123'); + expect(tx.sender).toBe('klv1sender'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.testnet.klever.org/v1.0/transaction/abc123', + expect.anything() + ); + }); + + it('throws on API error', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: null, error: 'transaction not found' }) + ); + + await expect(client.getTransaction('bad')).rejects.toThrow('transaction not found'); + }); + }); + + describe('getBlock', () => { + it('fetches block by nonce', async () => { + const blockData = { + hash: 'blockhash', + nonce: 100, + timestamp: 1700000000, + numTxs: 5, + }; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { block: blockData }, error: '' }) + ); + + const block = await client.getBlock(100); + expect(block.nonce).toBe(100); + expect(block.hash).toBe('blockhash'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.testnet.klever.org/v1.0/block/by-nonce/100', + expect.anything() + ); + }); + + it('fetches latest block when no nonce specified', async () => { + const blockData = { + hash: 'latest', + nonce: 999, + timestamp: 1700000000, + }; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { blocks: [blockData] }, error: '' }) + ); + + const block = await client.getBlock(); + expect(block.nonce).toBe(999); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.testnet.klever.org/v1.0/block/list?limit=1', + expect.anything() + ); + }); + + it('throws when no blocks returned for latest', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { blocks: [] }, error: '' }) + ); + + await expect(client.getBlock()).rejects.toThrow('No blocks returned'); + }); + }); + + describe('listValidators', () => { + it('fetches validator list', async () => { + const validators = [ + { ownerAddress: 'klv1val1', name: 'Validator 1', commission: 10 }, + { ownerAddress: 'klv1val2', name: 'Validator 2', commission: 5 }, + ]; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { validators }, error: '' }) + ); + + const result = await client.listValidators(); + expect(result).toHaveLength(2); + expect(result[0].ownerAddress).toBe('klv1val1'); + }); + + it('returns empty array when no validators', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { validators: null }, error: '' }) + ); + + const result = await client.listValidators(); + expect(result).toEqual([]); + }); + }); + + describe('getNonce', () => { + it('fetches current nonce for address', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { nonce: 15 }, error: '', code: 'successful' }) + ); + + const nonce = await client.getNonce('klv1sender'); + expect(nonce).toBe(15); + }); + }); + + describe('buildTransaction', () => { + it('builds an unsigned transaction', async () => { + const buildResult = { + result: { + txHash: 'hash123', + tx: 'proto_encoded_tx', + }, + }; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: buildResult, error: '', code: 'successful' }) + ); + + const result = await client.buildTransaction({ + type: 0, + sender: 'klv1sender', + nonce: 5, + contract: [{ type: 0, parameter: { amount: 1000000, toAddress: 'klv1receiver' } }], + }); + + expect(result.result.txHash).toBe('hash123'); + expect(result.result.tx).toBe('proto_encoded_tx'); + }); + }); + + describe('getNodeStatus', () => { + it('fetches node health status', async () => { + const statusData = { + isRunning: true, + isSynced: true, + currentNonce: 1000, + currentBlockHash: 'hash', + appVersion: '1.0.0', + }; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: statusData, error: '', code: 'successful' }) + ); + + const status = await client.getNodeStatus(); + expect(status.isRunning).toBe(true); + expect(status.isSynced).toBe(true); + }); + }); + + describe('error handling', () => { + it('handles fetch timeout', async () => { + const timeoutClient = new KleverChainClient({ network: 'testnet', timeout: 1 }); + mockFetch.mockImplementation( + () => + new Promise((_, reject) => { + const error = new Error('AbortError'); + error.name = 'AbortError'; + setTimeout(() => reject(error), 10); + }) + ); + + await expect(timeoutClient.getBalance('klv1test')).rejects.toThrow(); + }); + + it('handles network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + await expect(client.getBalance('klv1test')).rejects.toThrow('ECONNREFUSED'); + }); + }); +}); diff --git a/src/chain/client.ts b/src/chain/client.ts new file mode 100644 index 0000000..f15901c --- /dev/null +++ b/src/chain/client.ts @@ -0,0 +1,334 @@ +/** + * Klever Blockchain HTTP Client + * + * Provides methods for querying the Klever blockchain via Node API and API Proxy. + * Uses native fetch (Node 18+) — no external HTTP dependencies. + */ + +import type { + KleverNetwork, + NetworkConfig, + KleverResponse, + AccountData, + BalanceData, + AssetData, + KDAData, + VMQueryRequest, + VMQueryData, + TransactionData, + BlockData, + ValidatorData, + NodeStatusData, + TransactionBuildRequest, + TransactionBuildData, +} from './types.js'; + +/** Network URL mapping */ +const NETWORK_CONFIGS: Record = { + mainnet: { + nodeUrl: 'https://node.mainnet.klever.org', + apiUrl: 'https://api.mainnet.klever.org', + }, + testnet: { + nodeUrl: 'https://node.testnet.klever.org', + apiUrl: 'https://api.testnet.klever.org', + }, + devnet: { + nodeUrl: 'https://node.devnet.klever.org', + apiUrl: 'https://api.devnet.klever.org', + }, + local: { + nodeUrl: 'http://localhost:8080', + apiUrl: 'http://localhost:9090', + }, +}; + +export interface ChainClientOptions { + /** Default network (can be overridden per-call) */ + network?: KleverNetwork; + /** Request timeout in milliseconds (default: 15000) */ + timeout?: number; + /** Custom node URL (overrides network-based URL) */ + nodeUrl?: string; + /** Custom API URL (overrides network-based URL) */ + apiUrl?: string; +} + +export class KleverChainClient { + private defaultNetwork: KleverNetwork; + private timeout: number; + private customNodeUrl?: string; + private customApiUrl?: string; + + constructor(options: ChainClientOptions = {}) { + this.defaultNetwork = options.network || 'mainnet'; + this.timeout = options.timeout || 15000; + this.customNodeUrl = options.nodeUrl; + this.customApiUrl = options.apiUrl; + } + + /** Get the configured default network */ + getDefaultNetwork(): KleverNetwork { + return this.defaultNetwork; + } + + /** Resolve node URL for a given network */ + getNodeUrl(network?: KleverNetwork): string { + if (this.customNodeUrl) return this.customNodeUrl; + return NETWORK_CONFIGS[network || this.defaultNetwork].nodeUrl; + } + + /** Resolve API proxy URL for a given network */ + getApiUrl(network?: KleverNetwork): string { + if (this.customApiUrl) return this.customApiUrl; + return NETWORK_CONFIGS[network || this.defaultNetwork].apiUrl; + } + + // ─── Core HTTP Methods ─────────────────────────────────── + + private async fetchJson(url: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || response.statusText}`); + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Request timed out after ${this.timeout}ms: ${url}`); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + private async postJson(url: string, body: unknown): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url, { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || response.statusText}`); + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Request timed out after ${this.timeout}ms: ${url}`); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + /** Unwrap a Klever API response, throwing on error */ + private unwrap(response: KleverResponse, context: string): T { + if (response.code !== 'successful' || response.error) { + throw new Error(`${context}: ${response.error || `code=${response.code}`}`); + } + return response.data; + } + + // ─── Account Operations ────────────────────────────────── + + /** Get full account details */ + async getAccount( + address: string, + network?: KleverNetwork + ): Promise { + const nodeUrl = this.getNodeUrl(network); + const response = await this.fetchJson>( + `${nodeUrl}/address/${address}` + ); + return this.unwrap(response, `getAccount(${address})`).account; + } + + /** Get KLV balance for an address */ + async getBalance( + address: string, + assetId?: string, + network?: KleverNetwork + ): Promise { + const nodeUrl = this.getNodeUrl(network); + const url = assetId + ? `${nodeUrl}/address/${address}/balance?asset=${encodeURIComponent(assetId)}` + : `${nodeUrl}/address/${address}/balance`; + + const response = await this.fetchJson>(url); + return this.unwrap(response, `getBalance(${address})`).balance; + } + + /** Get detailed KDA token info (includes staking data) */ + async getKDAInfo( + address: string, + assetId: string, + network?: KleverNetwork + ): Promise { + const nodeUrl = this.getNodeUrl(network); + const response = await this.fetchJson>( + `${nodeUrl}/address/${address}/kda?asset=${encodeURIComponent(assetId)}` + ); + return this.unwrap(response, `getKDAInfo(${address}, ${assetId})`); + } + + /** Get current nonce for an address */ + async getNonce( + address: string, + network?: KleverNetwork + ): Promise { + const nodeUrl = this.getNodeUrl(network); + const response = await this.fetchJson>( + `${nodeUrl}/address/${address}/nonce` + ); + return this.unwrap(response, `getNonce(${address})`).nonce; + } + + // ─── Asset Operations ──────────────────────────────────── + + /** Get complete asset properties */ + async getAssetInfo( + assetId: string, + network?: KleverNetwork + ): Promise { + const nodeUrl = this.getNodeUrl(network); + const response = await this.fetchJson>( + `${nodeUrl}/asset/${encodeURIComponent(assetId)}` + ); + const data = this.unwrap(response, `getAssetInfo(${assetId})`); + return data.asset; + } + + // ─── Smart Contract Operations ─────────────────────────── + + /** Execute a read-only smart contract query */ + async querySmartContract( + request: VMQueryRequest, + network?: KleverNetwork + ): Promise { + const nodeUrl = this.getNodeUrl(network); + const response = await this.postJson>( + `${nodeUrl}/vm/query`, + request + ); + return this.unwrap(response, `querySmartContract(${request.scAddress}::${request.funcName})`); + } + + // ─── Transaction Operations ────────────────────────────── + + /** Get transaction details by hash (from node) */ + async getTransaction( + hash: string, + network?: KleverNetwork + ): Promise { + const apiUrl = this.getApiUrl(network); + // Use API proxy for indexed transaction data (more complete) + const response = await this.fetchJson<{ + data: { transaction: TransactionData }; + error: string; + code: string; + }>(`${apiUrl}/v1.0/transaction/${hash}`); + + if (response.error) { + throw new Error(`getTransaction(${hash}): ${response.error}`); + } + return response.data.transaction; + } + + /** Build an unsigned transaction */ + async buildTransaction( + request: TransactionBuildRequest, + network?: KleverNetwork + ): Promise { + const nodeUrl = this.getNodeUrl(network); + const response = await this.postJson>( + `${nodeUrl}/transaction/send`, + request + ); + return this.unwrap(response, 'buildTransaction'); + } + + // ─── Block Operations ──────────────────────────────────── + + /** Get block by nonce (number) */ + async getBlock( + nonce?: number, + network?: KleverNetwork + ): Promise { + const apiUrl = this.getApiUrl(network); + + if (nonce !== undefined) { + const response = await this.fetchJson<{ + data: { block: BlockData }; + error: string; + }>(`${apiUrl}/v1.0/block/by-nonce/${nonce}`); + if (response.error) { + throw new Error(`getBlock(${nonce}): ${response.error}`); + } + return response.data.block; + } + + // No nonce = latest block + const response = await this.fetchJson<{ + data: { blocks: BlockData[] }; + error: string; + }>(`${apiUrl}/v1.0/block/list?limit=1`); + if (response.error) { + throw new Error(`getBlock(latest): ${response.error}`); + } + if (!response.data.blocks || response.data.blocks.length === 0) { + throw new Error('getBlock(latest): No blocks returned'); + } + return response.data.blocks[0]; + } + + // ─── Network Operations ────────────────────────────────── + + /** List active validators */ + async listValidators( + network?: KleverNetwork + ): Promise { + const apiUrl = this.getApiUrl(network); + const response = await this.fetchJson<{ + data: { validators: ValidatorData[] }; + error: string; + }>(`${apiUrl}/v1.0/validators`); + + if (response.error) { + throw new Error(`listValidators: ${response.error}`); + } + return response.data.validators || []; + } + + /** Get node status */ + async getNodeStatus(network?: KleverNetwork): Promise { + const nodeUrl = this.getNodeUrl(network); + const response = await this.fetchJson>( + `${nodeUrl}/node/status` + ); + return this.unwrap(response, 'getNodeStatus'); + } +} + +export { NETWORK_CONFIGS }; diff --git a/src/chain/index.ts b/src/chain/index.ts new file mode 100644 index 0000000..3afcff2 --- /dev/null +++ b/src/chain/index.ts @@ -0,0 +1,19 @@ +export { KleverChainClient, NETWORK_CONFIGS } from './client.js'; +export type { ChainClientOptions } from './client.js'; +export type { + KleverNetwork, + NetworkConfig, + KleverResponse, + AccountData, + BalanceData, + AssetData, + KDAData, + VMQueryRequest, + VMQueryData, + TransactionData, + BlockData, + ValidatorData, + NodeStatusData, + TransactionBuildRequest, + TransactionBuildData, +} from './types.js'; diff --git a/src/chain/types.ts b/src/chain/types.ts new file mode 100644 index 0000000..b88bee8 --- /dev/null +++ b/src/chain/types.ts @@ -0,0 +1,243 @@ +/** + * Types for Klever blockchain API responses + */ + +/** Supported network environments */ +export type KleverNetwork = 'mainnet' | 'testnet' | 'devnet' | 'local'; + +/** Network URL configuration */ +export interface NetworkConfig { + nodeUrl: string; + apiUrl: string; +} + +/** Standard Klever API response wrapper */ +export interface KleverResponse { + data: T; + error: string; + code: string; +} + +/** Account data from GET /address/{address} */ +export interface AccountData { + address: string; + nonce: number; + balance: number; + frozenBalance: number; + allowance: number; + permissions: string[]; + timestamp: number; + rootHash?: string; + assets?: Record; +} + +/** Asset balance within an account */ +export interface AssetBalance { + assetId: string; + balance: number; + frozenBalance?: number; + unfrozenBalance?: number; + lastClaim?: Record; +} + +/** Balance response from GET /address/{address}/balance */ +export interface BalanceData { + balance: number; +} + +/** Nonce response from GET /address/{address}/nonce */ +export interface NonceData { + nonce: number; +} + +/** Asset properties from GET /asset/{assetID} */ +export interface AssetData { + asset: { + ID: string; + Name: string; + Ticker: string; + Logo?: string; + URIs?: Record; + OwnerAddress: string; + AdminAddress?: string; + Precision: number; + InitialSupply: number; + CirculatingSupply: number; + MaxSupply?: number; + MintedValue: number; + BurnedValue?: number; + IssueDate: number; + Royalties?: { + Address: string; + TransferPercentage?: number[]; + MarketPercentage?: number; + MarketFixedAmount?: number; + }; + Properties: { + CanFreeze?: boolean; + CanWipe?: boolean; + CanPause?: boolean; + CanMint?: boolean; + CanBurn?: boolean; + CanChangeOwner?: boolean; + CanAddRoles?: boolean; + }; + Attributes?: Record; + Roles?: Array<{ + Address: string; + HasRoleMint?: boolean; + HasRoleSetITOPrices?: boolean; + HasRoleDeposit?: boolean; + HasRoleTransfer?: boolean; + }>; + }; +} + +/** KDA token info from GET /address/{address}/kda?asset={KDA-ID} */ +export interface KDAData { + address: string; + asset: string; + userKDA: { + LastClaim?: Record; + [key: string]: unknown; + }; +} + +/** VM query request body */ +export interface VMQueryRequest { + scAddress: string; + funcName: string; + args?: string[]; +} + +/** VM query response from POST /vm/query */ +export interface VMQueryData { + returnData?: string[]; + returnCode?: string; + returnMessage?: string; + gasUsed?: number; +} + +/** Transaction data from GET /transaction/{hash} */ +export interface TransactionData { + hash: string; + sender: string; + receiver?: string; + nonce?: number; + status?: string; + resultCode?: string; + blockNum?: number; + blockHash?: string; + timestamp?: number; + contract?: Array<{ + type: number; + parameter: Record; + }>; + receipts?: Array<{ + type: number; + data?: Record; + }>; + [key: string]: unknown; +} + +/** Block data from GET /block/by-nonce/{nonce} */ +export interface BlockData { + hash: string; + nonce: number; + timestamp: number; + prevHash?: string; + proposer?: string; + numTxs?: number; + size?: number; + epoch?: number; + [key: string]: unknown; +} + +/** Validator info from GET /validators or /v1.0/validators */ +export interface ValidatorData { + ownerAddress: string; + name?: string; + canDelegate?: boolean; + commission?: number; + maxDelegation?: number; + totalStaked?: number; + selfStaked?: number; + rating?: number; + [key: string]: unknown; +} + +/** Node status from GET /node/status */ +export interface NodeStatusData { + isRunning: boolean; + isSynced: boolean; + currentNonce: number; + currentBlockHash: string; + appVersion: string; + [key: string]: unknown; +} + +/** Transaction build request for POST /transaction/send */ +export interface TransactionBuildRequest { + type: number; + sender: string; + nonce: number; + contract: Array<{ + type: number; + parameter: Record; + }>; + data?: string[]; + kdaFee?: { + kda: string; + amount: number; + }; +} + +/** Transaction build response from POST /transaction/send */ +export interface TransactionBuildData { + result: { + txHash: string; + tx: string; + }; +} + +/** Transaction broadcast response from POST /transactions/broadcast */ +export interface TransactionBroadcastData { + txHash: string; +} + +/** API proxy transaction response (indexed data) */ +export interface APITransactionData { + data: { + transaction: TransactionData; + }; +} + +/** API proxy block list response */ +export interface APIBlockListData { + data: { + blocks: BlockData[]; + }; + pagination?: { + self: number; + next: number; + previous: number; + perPage: number; + totalPages: number; + totalRecords: number; + }; +} + +/** API proxy validators response */ +export interface APIValidatorsData { + data: { + validators: ValidatorData[]; + }; + pagination?: { + self: number; + next: number; + previous: number; + perPage: number; + totalPages: number; + totalRecords: number; + }; +} diff --git a/src/index.ts b/src/index.ts index 9d4828f..2df97d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import { createRoutes } from './api/routes.js'; import { KleverMCPServer } from './mcp/server.js'; import { autoIngestKnowledge } from './utils/auto-ingest.js'; import { getVersionInfo } from './version.js'; +import { KleverChainClient } from './chain/index.js'; +import type { KleverNetwork } from './chain/types.js'; // Load environment variables dotenv.config({ quiet: true }); @@ -36,6 +38,16 @@ function createStorageAndService() { return { storageType, contextService }; } +function createChainClient(): KleverChainClient { + const network = (process.env.KLEVER_NETWORK as KleverNetwork) || 'mainnet'; + return new KleverChainClient({ + network, + nodeUrl: process.env.KLEVER_NODE_URL, + apiUrl: process.env.KLEVER_API_URL, + timeout: parseInt(process.env.KLEVER_TIMEOUT || '15000'), + }); +} + async function startHTTPServer() { const { storageType, contextService } = createStorageAndService(); @@ -88,7 +100,8 @@ async function startMCPServer() { } // Create and start MCP server - const mcpServer = new KleverMCPServer(contextService); + const chainClient = createChainClient(); + const mcpServer = new KleverMCPServer(contextService, 'local', chainClient); await mcpServer.start(); } @@ -222,7 +235,8 @@ async function startPublicServer() { }); // Create a public-profile MCP server and connect the transport - const mcpServer = new KleverMCPServer(contextService, 'public'); + const chainClient = createChainClient(); + const mcpServer = new KleverMCPServer(contextService, 'public', chainClient); await mcpServer.connectTransport(transport); // Handle the request diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 62f0d03..5cab44f 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -4,6 +4,32 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { ContextService } from '../context/service.js'; import { InMemoryStorage } from '../storage/memory.js'; import { KleverMCPServer } from './server.js'; +import { KleverChainClient } from '../chain/index.js'; + +// Mock global fetch for chain tools +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +function jsonResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + redirected: false, + type: 'basic' as ResponseType, + url: '', + clone: () => jsonResponse(data, status), + body: null, + bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + bytes: () => Promise.resolve(new Uint8Array()), + }; +} describe('KleverMCPServer (public mode)', () => { let client: Client; @@ -387,4 +413,246 @@ pub trait MyContract { ); }); }); + + describe('chain read tools (public mode)', () => { + it('lists chain read tools in public mode', async () => { + const { tools } = await client.listTools(); + const names = tools.map(t => t.name); + + expect(names).toContain('get_balance'); + expect(names).toContain('get_account'); + expect(names).toContain('get_asset_info'); + expect(names).toContain('query_sc'); + expect(names).toContain('get_transaction'); + expect(names).toContain('get_block'); + expect(names).toContain('list_validators'); + }); + + it('does not list chain write tools in public mode', async () => { + const { tools } = await client.listTools(); + const names = tools.map(t => t.name); + + expect(names).not.toContain('send_transfer'); + expect(names).not.toContain('deploy_sc'); + expect(names).not.toContain('invoke_sc'); + expect(names).not.toContain('freeze_klv'); + }); + + it('chain read tools are marked readOnlyHint: true', async () => { + const { tools } = await client.listTools(); + const chainReadTools = ['get_balance', 'get_account', 'get_asset_info', 'query_sc', 'get_transaction', 'get_block', 'list_validators']; + + for (const name of chainReadTools) { + const tool = tools.find(t => t.name === name); + expect(tool).toBeDefined(); + expect(tool!.annotations!.readOnlyHint).toBe(true); + expect(tool!.annotations!.openWorldHint).toBe(true); + } + }); + + it('get_balance returns formatted result', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { balance: 5000000 }, error: '', code: 'successful' }) + ); + + const result = await client.callTool({ + name: 'get_balance', + arguments: { address: 'klv1test' }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.balance).toBe(5000000); + expect(parsed.assetId).toBe('KLV'); + expect(parsed.formatted).toContain('KLV'); + }); + + it('get_account returns account data', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { account: { address: 'klv1test', nonce: 5, balance: 1000000 } }, + error: '', + code: 'successful', + }) + ); + + const result = await client.callTool({ + name: 'get_account', + arguments: { address: 'klv1test' }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.data.address).toBe('klv1test'); + }); + + it('list_validators returns array', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { validators: [{ ownerAddress: 'klv1val1', name: 'Val 1' }] }, + error: '', + }) + ); + + const result = await client.callTool({ + name: 'list_validators', + arguments: {}, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.total).toBe(1); + }); + }); + + describe('chain write tools blocked in public mode', () => { + it('blocks send_transfer', async () => { + const result = await client.callTool({ + name: 'send_transfer', + arguments: { sender: 'klv1a', receiver: 'klv1b', amount: 1000000 }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error).toContain('not available in public mode'); + }); + + it('blocks deploy_sc', async () => { + const result = await client.callTool({ + name: 'deploy_sc', + arguments: { sender: 'klv1a', wasmHex: 'deadbeef' }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error).toContain('not available in public mode'); + }); + + it('blocks invoke_sc', async () => { + const result = await client.callTool({ + name: 'invoke_sc', + arguments: { sender: 'klv1a', scAddress: 'klv1sc', funcName: 'test' }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(false); + }); + + it('blocks freeze_klv', async () => { + const result = await client.callTool({ + name: 'freeze_klv', + arguments: { sender: 'klv1a', amount: 1000000 }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(false); + }); + }); +}); + +describe('KleverMCPServer (local mode)', () => { + let client: Client; + let consoleErrorSpy: ReturnType; + + beforeAll(async () => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((() => {}) as () => void); + + const storage = new InMemoryStorage(); + const contextService = new ContextService(storage); + const chainClient = new KleverChainClient({ network: 'testnet' }); + const server = new KleverMCPServer(contextService, 'local', chainClient); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connectTransport(serverTransport); + + client = new Client({ name: 'test-client-local', version: '1.0.0' }); + await client.connect(clientTransport); + }); + + afterAll(async () => { + await client.close(); + consoleErrorSpy.mockRestore(); + }); + + it('lists chain write tools in local mode', async () => { + const { tools } = await client.listTools(); + const names = tools.map(t => t.name); + + expect(names).toContain('send_transfer'); + expect(names).toContain('deploy_sc'); + expect(names).toContain('invoke_sc'); + expect(names).toContain('freeze_klv'); + }); + + it('lists chain read tools in local mode', async () => { + const { tools } = await client.listTools(); + const names = tools.map(t => t.name); + + expect(names).toContain('get_balance'); + expect(names).toContain('get_account'); + expect(names).toContain('get_asset_info'); + expect(names).toContain('query_sc'); + expect(names).toContain('get_transaction'); + expect(names).toContain('get_block'); + expect(names).toContain('list_validators'); + }); + + it('send_transfer builds unsigned transaction', async () => { + // Mock getNonce + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { nonce: 10 }, error: '', code: 'successful' }) + ); + // Mock buildTransaction + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { result: { txHash: 'hash123', tx: 'proto_tx' } }, + error: '', + code: 'successful', + }) + ); + + const result = await client.callTool({ + name: 'send_transfer', + arguments: { sender: 'klv1sender', receiver: 'klv1receiver', amount: 5000000 }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.txHash).toBe('hash123'); + expect(parsed.unsignedTx).toBe('proto_tx'); + expect(parsed.details.amount).toBe(5000000); + expect(parsed.nextSteps).toBeDefined(); + expect(parsed.message).toContain('Unsigned'); + }); + + it('freeze_klv builds unsigned transaction', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { nonce: 5 }, error: '', code: 'successful' }) + ); + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { result: { txHash: 'freezehash', tx: 'freeze_proto' } }, + error: '', + code: 'successful', + }) + ); + + const result = await client.callTool({ + name: 'freeze_klv', + arguments: { sender: 'klv1sender', amount: 10000000 }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.details.formattedAmount).toBe('10.000000 KLV'); + }); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2f73c72..2d6271b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -14,6 +14,8 @@ import { ContextService } from '../context/service.js'; import { QueryContextSchema, ContextPayloadSchema } from '../types/index.js'; import { VERSION, GIT_SHA } from '../version.js'; import { KNOWLEDGE_CATEGORIES } from './resources.js'; +import { KleverChainClient } from '../chain/index.js'; +import type { KleverNetwork, VMQueryRequest } from '../chain/types.js'; export type ServerProfile = 'local' | 'public'; @@ -38,12 +40,15 @@ function toExecError(error: unknown): ExecError { export class KleverMCPServer { private server: Server; private profile: ServerProfile; + private chainClient: KleverChainClient; constructor( private contextService: ContextService, - profile: ServerProfile = 'local' + profile: ServerProfile = 'local', + chainClient?: KleverChainClient ) { this.profile = profile; + this.chainClient = chainClient || new KleverChainClient(); this.server = new Server( { name: 'klever-vm-mcp', @@ -286,6 +291,335 @@ export class KleverMCPServer { ]; } + private getChainReadToolDefinitions() { + const networkDesc = `Network to query. Options: "mainnet", "testnet", "devnet", "local". Defaults to server default (${this.chainClient.getDefaultNetwork()}).`; + return [ + { + name: 'get_balance', + description: + 'Get the KLV or KDA token balance for a Klever blockchain address. Returns the balance in the smallest unit (for KLV: 1 KLV = 1,000,000 units with 6 decimal places). Optionally specify an asset ID to query a specific KDA token balance instead of KLV.', + inputSchema: { + type: 'object' as const, + properties: { + address: { + type: 'string', + description: 'Klever address (klv1... bech32 format).', + }, + assetId: { + type: 'string', + description: + 'Optional KDA token ID (e.g. "USDT-A1B2", "LPKLVKFI-3I0N"). Omit for KLV balance.', + }, + network: { type: 'string', description: networkDesc }, + }, + required: ['address'], + }, + annotations: { + title: 'Get Balance', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: 'get_account', + description: + 'Get full account details for a Klever blockchain address including nonce, balance, frozen balance, allowance, and permissions. Use this when you need comprehensive account state beyond just the balance.', + inputSchema: { + type: 'object' as const, + properties: { + address: { + type: 'string', + description: 'Klever address (klv1... bech32 format).', + }, + network: { type: 'string', description: networkDesc }, + }, + required: ['address'], + }, + annotations: { + title: 'Get Account', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: 'get_asset_info', + description: + 'Get complete properties and configuration for any asset on the Klever blockchain (KLV, KFI, KDA tokens, NFT collections). Returns supply info, permissions (CanMint, CanBurn, etc.), roles, precision, and metadata. Note: string fields like ID, Name, Ticker are base64-encoded in the raw response.', + inputSchema: { + type: 'object' as const, + properties: { + assetId: { + type: 'string', + description: + 'Asset identifier (e.g. "KLV", "KFI", "USDT-A1B2", "MYNFT-XY78").', + }, + network: { type: 'string', description: networkDesc }, + }, + required: ['assetId'], + }, + annotations: { + title: 'Get Asset Info', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: 'query_sc', + description: + 'Execute a read-only query against a Klever smart contract (VM view call). Returns the contract function result as base64-encoded return data. Arguments must be base64-encoded. Use this to read contract state without modifying it.', + inputSchema: { + type: 'object' as const, + properties: { + scAddress: { + type: 'string', + description: 'Smart contract address (klv1... bech32 format).', + }, + funcName: { + type: 'string', + description: + 'Function name to call (must be a #[view] function on the contract).', + }, + args: { + type: 'array', + items: { type: 'string' }, + description: + 'Optional base64-encoded arguments. For addresses, encode the hex-decoded bech32 bytes. For numbers, use big-endian byte encoding.', + }, + network: { type: 'string', description: networkDesc }, + }, + required: ['scAddress', 'funcName'], + }, + annotations: { + title: 'Query Smart Contract', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: 'get_transaction', + description: + 'Get transaction details by hash from the Klever blockchain. Returns sender, receiver, status, block info, contracts, and receipts. Uses the API proxy for indexed data.', + inputSchema: { + type: 'object' as const, + properties: { + hash: { + type: 'string', + description: 'Transaction hash (hex string).', + }, + network: { type: 'string', description: networkDesc }, + }, + required: ['hash'], + }, + annotations: { + title: 'Get Transaction', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: 'get_block', + description: + 'Get block information from the Klever blockchain by nonce (block number). If no nonce is provided, returns the latest block. Returns hash, timestamp, proposer, number of transactions, and other block metadata.', + inputSchema: { + type: 'object' as const, + properties: { + nonce: { + type: 'integer', + minimum: 0, + description: + 'Block number (nonce). Omit to get the latest block.', + }, + network: { type: 'string', description: networkDesc }, + }, + }, + annotations: { + title: 'Get Block', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: 'list_validators', + description: + 'List active validators on the Klever blockchain network. Returns validator addresses, names, commission rates, delegation info, and staking amounts.', + inputSchema: { + type: 'object' as const, + properties: { + network: { type: 'string', description: networkDesc }, + }, + }, + annotations: { + title: 'List Validators', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + ]; + } + + private getChainWriteToolDefinitions() { + const networkDesc = `Network to use. Options: "mainnet", "testnet", "devnet", "local". Defaults to server default (${this.chainClient.getDefaultNetwork()}).`; + return [ + { + name: 'send_transfer', + description: + 'Build an unsigned KLV or KDA token transfer transaction on the Klever blockchain. Returns the unsigned transaction data and hash for client-side signing. The MCP server NEVER handles private keys — signing must be done externally.', + inputSchema: { + type: 'object' as const, + properties: { + sender: { + type: 'string', + description: 'Sender address (klv1... bech32 format).', + }, + receiver: { + type: 'string', + description: 'Receiver address (klv1... bech32 format).', + }, + amount: { + type: 'integer', + minimum: 1, + description: + 'Amount in the smallest unit. For KLV: 1 KLV = 1,000,000 units (6 decimals). Example: to send 10 KLV, use 10000000.', + }, + assetId: { + type: 'string', + description: + 'Optional KDA token ID for non-KLV transfers (e.g. "USDT-A1B2"). Omit for KLV.', + }, + network: { type: 'string', description: networkDesc }, + }, + required: ['sender', 'receiver', 'amount'], + }, + annotations: { + title: 'Build Transfer Transaction', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: 'deploy_sc', + description: + 'Build an unsigned smart contract deployment transaction for the Klever blockchain. Provide the WASM bytecode as a hex string. Returns the unsigned transaction for client-side signing. The MCP server NEVER handles private keys.', + inputSchema: { + type: 'object' as const, + properties: { + sender: { + type: 'string', + description: 'Deployer address (klv1... bech32 format).', + }, + wasmHex: { + type: 'string', + description: + 'Smart contract WASM bytecode as a hex-encoded string.', + }, + initArgs: { + type: 'array', + items: { type: 'string' }, + description: + 'Optional base64-encoded init arguments for the contract constructor.', + }, + network: { type: 'string', description: networkDesc }, + }, + required: ['sender', 'wasmHex'], + }, + annotations: { + title: 'Build Deploy SC Transaction', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: 'invoke_sc', + description: + 'Build an unsigned smart contract invocation transaction on the Klever blockchain. Calls a state-changing endpoint on a deployed contract. Returns the unsigned transaction for client-side signing. For read-only calls, use query_sc instead.', + inputSchema: { + type: 'object' as const, + properties: { + sender: { + type: 'string', + description: 'Caller address (klv1... bech32 format).', + }, + scAddress: { + type: 'string', + description: 'Smart contract address (klv1... bech32 format).', + }, + funcName: { + type: 'string', + description: 'Endpoint function name to invoke.', + }, + args: { + type: 'array', + items: { type: 'string' }, + description: 'Optional base64-encoded arguments.', + }, + value: { + type: 'integer', + minimum: 0, + description: + 'Optional KLV amount to send with the call (smallest unit). Required for payable endpoints.', + }, + network: { type: 'string', description: networkDesc }, + }, + required: ['sender', 'scAddress', 'funcName'], + }, + annotations: { + title: 'Build Invoke SC Transaction', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: 'freeze_klv', + description: + 'Build an unsigned Freeze KLV transaction on the Klever blockchain. Freezing KLV provides energy/bandwidth for network operations and enables staking rewards. Returns the unsigned transaction for client-side signing.', + inputSchema: { + type: 'object' as const, + properties: { + sender: { + type: 'string', + description: 'Address to freeze from (klv1... bech32 format).', + }, + amount: { + type: 'integer', + minimum: 1, + description: + 'Amount of KLV to freeze in the smallest unit (1 KLV = 1,000,000 units).', + }, + network: { type: 'string', description: networkDesc }, + }, + required: ['sender', 'amount'], + }, + annotations: { + title: 'Build Freeze KLV Transaction', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + ]; + } + private async getPublicModeToolDefinitions() { const { projectInitToolDefinition, addHelperScriptsToolDefinition } = await import( '../utils/project-init-script.js' @@ -401,12 +735,16 @@ export class KleverMCPServer { private setupHandlers() { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => { - const tools: Array> = [...this.getReadOnlyToolDefinitions()]; + const tools: Array> = [ + ...this.getReadOnlyToolDefinitions(), + ...this.getChainReadToolDefinitions(), + ]; if (this.profile === 'public') { const publicTools = await this.getPublicModeToolDefinitions(); tools.push(...publicTools); } else { + tools.push(...this.getChainWriteToolDefinitions()); const localTools = await this.getLocalOnlyToolDefinitions(); tools.push(...localTools); } @@ -422,10 +760,16 @@ export class KleverMCPServer { console.error(`[MCP] Tool called: ${name}`, JSON.stringify(args)); // Block local-only tools in public profile - if ( - this.profile === 'public' && - ['add_context', 'check_sdk_status', 'install_klever_sdk'].includes(name) - ) { + const localOnlyTools = [ + 'add_context', + 'check_sdk_status', + 'install_klever_sdk', + 'send_transfer', + 'deploy_sc', + 'invoke_sc', + 'freeze_klv', + ]; + if (this.profile === 'public' && localOnlyTools.includes(name)) { return { content: [ { @@ -433,9 +777,9 @@ export class KleverMCPServer { text: JSON.stringify( { success: false, - error: `Tool "${name}" is not available in public mode. Public mode does not allow local-only or environment-modifying tools.`, + error: `Tool "${name}" is not available in public mode. Public mode does not allow local-only, write, or environment-modifying tools.`, suggestion: - 'Use query_context, search_documentation, or analyze_contract to explore the knowledge base. For project scaffolding in public mode, init_klever_project and add_helper_scripts return template files instead of modifying your local environment.', + 'Use query_context, search_documentation, or analyze_contract to explore the knowledge base. On-chain read tools (get_balance, get_account, get_asset_info, query_sc, get_transaction, get_block, list_validators) are available in public mode.', availableTools: [ 'query_context', 'get_context', @@ -444,6 +788,13 @@ export class KleverMCPServer { 'enhance_with_context', 'search_documentation', 'analyze_contract', + 'get_balance', + 'get_account', + 'get_asset_info', + 'query_sc', + 'get_transaction', + 'get_block', + 'list_validators', 'init_klever_project', 'add_helper_scripts', ], @@ -1219,6 +1570,492 @@ export class KleverMCPServer { } } + // ─── Chain Read Tools ───────────────────────────── + + case 'get_balance': { + const { address, assetId, network } = args as { + address: string; + assetId?: string; + network?: string; + }; + console.error(`[MCP] get_balance: ${address} asset=${assetId || 'KLV'} network=${network || 'default'}`); + + const balance = await this.chainClient.getBalance( + address, + assetId, + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + address, + assetId: assetId || 'KLV', + balance, + formatted: assetId + ? `${balance} (raw units — check asset precision)` + : `${(balance / 1_000_000).toFixed(6)} KLV`, + network: network || this.chainClient.getDefaultNetwork(), + }, + null, + 2 + ), + }, + ], + }; + } + + case 'get_account': { + const { address, network } = args as { + address: string; + network?: string; + }; + console.error(`[MCP] get_account: ${address} network=${network || 'default'}`); + + const account = await this.chainClient.getAccount( + address, + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + data: account, + network: network || this.chainClient.getDefaultNetwork(), + }, + null, + 2 + ), + }, + ], + }; + } + + case 'get_asset_info': { + const { assetId, network } = args as { + assetId: string; + network?: string; + }; + console.error(`[MCP] get_asset_info: ${assetId} network=${network || 'default'}`); + + const asset = await this.chainClient.getAssetInfo( + assetId, + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + data: asset, + network: network || this.chainClient.getDefaultNetwork(), + }, + null, + 2 + ), + }, + ], + }; + } + + case 'query_sc': { + const { scAddress, funcName, args: scArgs, network } = args as { + scAddress: string; + funcName: string; + args?: string[]; + network?: string; + }; + console.error(`[MCP] query_sc: ${scAddress}::${funcName} network=${network || 'default'}`); + + const request: VMQueryRequest = { + scAddress, + funcName, + args: scArgs, + }; + + const result = await this.chainClient.querySmartContract( + request, + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + data: result, + network: network || this.chainClient.getDefaultNetwork(), + hint: 'returnData values are base64-encoded. Decode them based on the expected return type.', + }, + null, + 2 + ), + }, + ], + }; + } + + case 'get_transaction': { + const { hash, network } = args as { + hash: string; + network?: string; + }; + console.error(`[MCP] get_transaction: ${hash} network=${network || 'default'}`); + + const tx = await this.chainClient.getTransaction( + hash, + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + data: tx, + network: network || this.chainClient.getDefaultNetwork(), + }, + null, + 2 + ), + }, + ], + }; + } + + case 'get_block': { + const { nonce, network } = args as { + nonce?: number; + network?: string; + }; + console.error(`[MCP] get_block: nonce=${nonce ?? 'latest'} network=${network || 'default'}`); + + const block = await this.chainClient.getBlock( + nonce, + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + data: block, + network: network || this.chainClient.getDefaultNetwork(), + }, + null, + 2 + ), + }, + ], + }; + } + + case 'list_validators': { + const { network } = args as { network?: string }; + console.error(`[MCP] list_validators: network=${network || 'default'}`); + + const validators = await this.chainClient.listValidators( + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + total: validators.length, + data: validators, + network: network || this.chainClient.getDefaultNetwork(), + }, + null, + 2 + ), + }, + ], + }; + } + + // ─── Chain Write Tools (local only) ──────────────── + + case 'send_transfer': { + const { sender, receiver, amount, assetId, network } = args as { + sender: string; + receiver: string; + amount: number; + assetId?: string; + network?: string; + }; + console.error(`[MCP] send_transfer: ${sender} -> ${receiver} amount=${amount} asset=${assetId || 'KLV'}`); + + const nonce = await this.chainClient.getNonce( + sender, + network as KleverNetwork | undefined + ); + + const contract: Array<{ type: number; parameter: Record }> = [ + { + type: 0, // Transfer + parameter: { + amount, + toAddress: receiver, + ...(assetId ? { assetId } : {}), + }, + }, + ]; + + const txResult = await this.chainClient.buildTransaction( + { type: 0, sender, nonce, contract }, + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + message: + 'Unsigned transaction built successfully. Sign this transaction externally and broadcast it.', + txHash: txResult.result.txHash, + unsignedTx: txResult.result.tx, + details: { + sender, + receiver, + amount, + assetId: assetId || 'KLV', + nonce, + }, + network: network || this.chainClient.getDefaultNetwork(), + nextSteps: [ + '1. Sign the transaction hash with the sender private key', + '2. Broadcast the signed transaction to the network', + 'WARNING: The MCP server does NOT handle private keys.', + ], + }, + null, + 2 + ), + }, + ], + }; + } + + case 'deploy_sc': { + const { sender, wasmHex, initArgs, network } = args as { + sender: string; + wasmHex: string; + initArgs?: string[]; + network?: string; + }; + console.error(`[MCP] deploy_sc: sender=${sender} wasmSize=${wasmHex.length / 2} bytes`); + + const nonce = await this.chainClient.getNonce( + sender, + network as KleverNetwork | undefined + ); + + const data = [wasmHex, ...(initArgs || [])]; + + const contract: Array<{ type: number; parameter: Record }> = [ + { + type: 9, // SmartContract (Deploy) + parameter: { + type: 0, // Deploy + }, + }, + ]; + + const txResult = await this.chainClient.buildTransaction( + { type: 9, sender, nonce, contract, data }, + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + message: + 'Unsigned deploy transaction built. Sign externally and broadcast.', + txHash: txResult.result.txHash, + unsignedTx: txResult.result.tx, + details: { + sender, + wasmSize: `${wasmHex.length / 2} bytes`, + nonce, + }, + network: network || this.chainClient.getDefaultNetwork(), + nextSteps: [ + '1. Sign the transaction hash with the deployer private key', + '2. Broadcast the signed transaction', + '3. The contract address will be derived from the sender address + nonce', + ], + }, + null, + 2 + ), + }, + ], + }; + } + + case 'invoke_sc': { + const { + sender, + scAddress, + funcName, + args: scArgs, + value, + network, + } = args as { + sender: string; + scAddress: string; + funcName: string; + args?: string[]; + value?: number; + network?: string; + }; + console.error(`[MCP] invoke_sc: ${sender} -> ${scAddress}::${funcName}`); + + const nonce = await this.chainClient.getNonce( + sender, + network as KleverNetwork | undefined + ); + + const data = [funcName, ...(scArgs || [])]; + + const contract: Array<{ type: number; parameter: Record }> = [ + { + type: 9, // SmartContract (Invoke) + parameter: { + type: 1, // Invoke + callValue: value ? { amount: value } : undefined, + address: scAddress, + }, + }, + ]; + + const txResult = await this.chainClient.buildTransaction( + { type: 9, sender, nonce, contract, data }, + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + message: + 'Unsigned SC invoke transaction built. Sign externally and broadcast.', + txHash: txResult.result.txHash, + unsignedTx: txResult.result.tx, + details: { + sender, + scAddress, + funcName, + argsCount: scArgs?.length || 0, + value: value || 0, + nonce, + }, + network: network || this.chainClient.getDefaultNetwork(), + nextSteps: [ + '1. Sign the transaction hash with the caller private key', + '2. Broadcast the signed transaction', + '3. Check the transaction receipt for execution results', + ], + }, + null, + 2 + ), + }, + ], + }; + } + + case 'freeze_klv': { + const { sender, amount, network } = args as { + sender: string; + amount: number; + network?: string; + }; + console.error(`[MCP] freeze_klv: ${sender} amount=${amount}`); + + const nonce = await this.chainClient.getNonce( + sender, + network as KleverNetwork | undefined + ); + + const contract: Array<{ type: number; parameter: Record }> = [ + { + type: 2, // Freeze + parameter: { + amount, + }, + }, + ]; + + const txResult = await this.chainClient.buildTransaction( + { type: 2, sender, nonce, contract }, + network as KleverNetwork | undefined + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + message: + 'Unsigned freeze transaction built. Sign externally and broadcast.', + txHash: txResult.result.txHash, + unsignedTx: txResult.result.tx, + details: { + sender, + amount, + formattedAmount: `${(amount / 1_000_000).toFixed(6)} KLV`, + nonce, + }, + network: network || this.chainClient.getDefaultNetwork(), + nextSteps: [ + '1. Sign the transaction hash with the sender private key', + '2. Broadcast the signed transaction', + '3. Frozen KLV provides energy/bandwidth and enables staking rewards', + ], + }, + null, + 2 + ), + }, + ], + }; + } + default: return { content: [ From af4c01483bc32a94fb13b44fc28108a5ffcb5a15 Mon Sep 17 00:00:00 2001 From: Fernando Sobreira Date: Sun, 15 Feb 2026 10:11:15 -0500 Subject: [PATCH 2/7] fix: update klever-sc crate version from 0.44.0 to 0.45.0 in knowledge base --- src/knowledge/modules/admin.ts | 8 ++++---- src/knowledge/modules/pause.ts | 4 ++-- src/knowledge/tools/ksc.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/knowledge/modules/admin.ts b/src/knowledge/modules/admin.ts index b784a3f..d908fe5 100644 --- a/src/knowledge/modules/admin.ts +++ b/src/knowledge/modules/admin.ts @@ -34,7 +34,7 @@ pub trait AdminModule { \`\`\`rust // In Cargo.toml: [dependencies.klever-sc-modules] -version = "0.44.0" +version = "0.45.0" # check crates.io/crates/klever-sc for latest // In your contract file: use klever_sc_modules::only_admin; @@ -83,7 +83,7 @@ src/ // Step 1: Add dependency to Cargo.toml /* [dependencies.klever-sc-modules] -version = "0.44.0" +version = "0.45.0" # check crates.io/crates/klever-sc for latest */ // Step 2: Import the admin module @@ -153,7 +153,7 @@ The OnlyAdminModule is a built-in Klever SDK module that provides multi-admin ac ### 1. Add Dependency to Cargo.toml \`\`\`toml [dependencies.klever-sc-modules] -version = "0.44.0" +version = "0.45.0" # check crates.io/crates/klever-sc for latest \`\`\` ### 2. Import the Module @@ -300,7 +300,7 @@ Klever VM SDK provides a pre-built admin module in the \`klever-sc-modules\` cra ### 1. Add Dependency to Cargo.toml \`\`\`toml [dependencies.klever-sc-modules] -version = "0.44.0" +version = "0.45.0" # check crates.io/crates/klever-sc for latest \`\`\` ### 2. Import the Module diff --git a/src/knowledge/modules/pause.ts b/src/knowledge/modules/pause.ts index 5e120cf..2aaa64e 100644 --- a/src/knowledge/modules/pause.ts +++ b/src/knowledge/modules/pause.ts @@ -13,7 +13,7 @@ export const pauseModuleKnowledge: KnowledgeEntry[] = [ // Step 1: Add dependency to Cargo.toml /* [dependencies.klever-sc-modules] -version = "0.44.0" +version = "0.45.0" # check crates.io/crates/klever-sc for latest */ // Step 2: Import the pause module @@ -88,7 +88,7 @@ The PauseModule is a built-in Klever SDK module that provides contract pausabili ### 1. Add Dependency to Cargo.toml \`\`\`toml [dependencies.klever-sc-modules] -version = "0.44.0" +version = "0.45.0" # check crates.io/crates/klever-sc for latest \`\`\` ### 2. Import the Module diff --git a/src/knowledge/tools/ksc.ts b/src/knowledge/tools/ksc.ts index f8aeea0..02f3ba6 100644 --- a/src/knowledge/tools/ksc.ts +++ b/src/knowledge/tools/ksc.ts @@ -166,10 +166,10 @@ publish = false path = "src/lib.rs" [dependencies] -klever-sc = "0.44.0" +klever-sc = "0.45.0" # check crates.io/crates/klever-sc for latest [dev-dependencies] -klever-sc-scenario = "0.44.0" +klever-sc-scenario = "0.45.0" # check crates.io/crates/klever-sc for latest [profile.release] codegen-units = 1 From 45b1e6ba2ad1b2c1f360ebfb8d703a34d6d2e9e7 Mon Sep 17 00:00:00 2001 From: Fernando Sobreira Date: Sun, 15 Feb 2026 10:19:42 -0500 Subject: [PATCH 3/7] fix: validate network parameter in chain tool handlers --- src/mcp/server.test.ts | 12 ++++++++++++ src/mcp/server.ts | 42 +++++++++++++++++++++++++++--------------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 5cab44f..34a632c 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -450,6 +450,18 @@ pub trait MyContract { } }); + it('rejects invalid network parameter', async () => { + const result = await client.callTool({ + name: 'get_balance', + arguments: { address: 'klv1test', network: 'staging' }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain('Invalid network'); + expect(content[0].text).toContain('staging'); + expect(content[0].text).toContain('mainnet, testnet, devnet, local'); + }); + it('get_balance returns formatted result', async () => { mockFetch.mockResolvedValueOnce( jsonResponse({ data: { balance: 5000000 }, error: '', code: 'successful' }) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2d6271b..fadf9bc 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -19,6 +19,18 @@ import type { KleverNetwork, VMQueryRequest } from '../chain/types.js'; export type ServerProfile = 'local' | 'public'; +const VALID_NETWORKS = new Set(['mainnet', 'testnet', 'devnet', 'local']); + +function validateNetwork(network: string | undefined): KleverNetwork | undefined { + if (network === undefined) return undefined; + if (!VALID_NETWORKS.has(network)) { + throw new Error( + `Invalid network "${network}". Valid options: mainnet, testnet, devnet, local.` + ); + } + return network as KleverNetwork; +} + interface ExecError { message: string; stderr: string; @@ -1583,7 +1595,7 @@ export class KleverMCPServer { const balance = await this.chainClient.getBalance( address, assetId, - network as KleverNetwork | undefined + validateNetwork(network) ); return { @@ -1618,7 +1630,7 @@ export class KleverMCPServer { const account = await this.chainClient.getAccount( address, - network as KleverNetwork | undefined + validateNetwork(network) ); return { @@ -1648,7 +1660,7 @@ export class KleverMCPServer { const asset = await this.chainClient.getAssetInfo( assetId, - network as KleverNetwork | undefined + validateNetwork(network) ); return { @@ -1686,7 +1698,7 @@ export class KleverMCPServer { const result = await this.chainClient.querySmartContract( request, - network as KleverNetwork | undefined + validateNetwork(network) ); return { @@ -1717,7 +1729,7 @@ export class KleverMCPServer { const tx = await this.chainClient.getTransaction( hash, - network as KleverNetwork | undefined + validateNetwork(network) ); return { @@ -1747,7 +1759,7 @@ export class KleverMCPServer { const block = await this.chainClient.getBlock( nonce, - network as KleverNetwork | undefined + validateNetwork(network) ); return { @@ -1773,7 +1785,7 @@ export class KleverMCPServer { console.error(`[MCP] list_validators: network=${network || 'default'}`); const validators = await this.chainClient.listValidators( - network as KleverNetwork | undefined + validateNetwork(network) ); return { @@ -1809,7 +1821,7 @@ export class KleverMCPServer { const nonce = await this.chainClient.getNonce( sender, - network as KleverNetwork | undefined + validateNetwork(network) ); const contract: Array<{ type: number; parameter: Record }> = [ @@ -1825,7 +1837,7 @@ export class KleverMCPServer { const txResult = await this.chainClient.buildTransaction( { type: 0, sender, nonce, contract }, - network as KleverNetwork | undefined + validateNetwork(network) ); return { @@ -1872,7 +1884,7 @@ export class KleverMCPServer { const nonce = await this.chainClient.getNonce( sender, - network as KleverNetwork | undefined + validateNetwork(network) ); const data = [wasmHex, ...(initArgs || [])]; @@ -1888,7 +1900,7 @@ export class KleverMCPServer { const txResult = await this.chainClient.buildTransaction( { type: 9, sender, nonce, contract, data }, - network as KleverNetwork | undefined + validateNetwork(network) ); return { @@ -1942,7 +1954,7 @@ export class KleverMCPServer { const nonce = await this.chainClient.getNonce( sender, - network as KleverNetwork | undefined + validateNetwork(network) ); const data = [funcName, ...(scArgs || [])]; @@ -1960,7 +1972,7 @@ export class KleverMCPServer { const txResult = await this.chainClient.buildTransaction( { type: 9, sender, nonce, contract, data }, - network as KleverNetwork | undefined + validateNetwork(network) ); return { @@ -2007,7 +2019,7 @@ export class KleverMCPServer { const nonce = await this.chainClient.getNonce( sender, - network as KleverNetwork | undefined + validateNetwork(network) ); const contract: Array<{ type: number; parameter: Record }> = [ @@ -2021,7 +2033,7 @@ export class KleverMCPServer { const txResult = await this.chainClient.buildTransaction( { type: 2, sender, nonce, contract }, - network as KleverNetwork | undefined + validateNetwork(network) ); return { From 7e8c1a3000f68450b82b8d265993c9a76ce3077c Mon Sep 17 00:00:00 2001 From: Fernando Sobreira Date: Sun, 15 Feb 2026 10:40:14 -0500 Subject: [PATCH 4/7] fix: address PR review feedback for on-chain tools - Add enum constraint to all network properties in tool schemas - Validate KLEVER_NETWORK env var with fallback and warning - Truncate large string args in debug logging (prevents wasmHex flooding) - Save/restore global.fetch in test files for proper isolation - Add deploy_sc and invoke_sc local-mode integration tests - Add getKDAInfo unit tests (happy path, URL encoding, error) --- src/chain/client.test.ts | 48 +++++++++++++++++++++++++- src/index.ts | 11 +++++- src/mcp/server.test.ts | 74 +++++++++++++++++++++++++++++++++++++++- src/mcp/server.ts | 31 ++++++++++------- 4 files changed, 148 insertions(+), 16 deletions(-) diff --git a/src/chain/client.test.ts b/src/chain/client.test.ts index de8ab8c..22d831b 100644 --- a/src/chain/client.test.ts +++ b/src/chain/client.test.ts @@ -1,10 +1,15 @@ import { jest } from '@jest/globals'; import { KleverChainClient, NETWORK_CONFIGS } from './client.js'; -// Mock global fetch +// Mock global fetch (save original and restore in afterAll) +const originalFetch = global.fetch; const mockFetch = jest.fn(); global.fetch = mockFetch; +afterAll(() => { + global.fetch = originalFetch; +}); + function jsonResponse(data: unknown, status = 200): Response { return { ok: status >= 200 && status < 300, @@ -187,6 +192,47 @@ describe('KleverChainClient', () => { }); }); + describe('getKDAInfo', () => { + it('fetches KDA token info for an address', async () => { + const kdaData = { + balance: 500000, + frozenBalance: 0, + lastClaim: { timestamp: 0, epoch: 0 }, + }; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: kdaData, error: '', code: 'successful' }) + ); + + const result = await client.getKDAInfo('klv1test', 'USDT-A1B2'); + expect(result.balance).toBe(500000); + expect(mockFetch).toHaveBeenCalledWith( + 'https://node.testnet.klever.org/address/klv1test/kda?asset=USDT-A1B2', + expect.anything() + ); + }); + + it('URL-encodes the asset ID', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { balance: 100 }, error: '', code: 'successful' }) + ); + + await client.getKDAInfo('klv1test', 'LPKLVKFI-3I0N'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://node.testnet.klever.org/address/klv1test/kda?asset=LPKLVKFI-3I0N', + expect.anything() + ); + }); + + it('throws on API error', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: null, error: 'asset not found', code: 'internal_issue' }) + ); + + await expect(client.getKDAInfo('klv1test', 'FAKE-TOKEN')).rejects.toThrow('asset not found'); + }); + }); + describe('querySmartContract', () => { it('sends VM query and returns result', async () => { const vmResult = { diff --git a/src/index.ts b/src/index.ts index 2df97d6..0f082e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,8 +38,17 @@ function createStorageAndService() { return { storageType, contextService }; } +const VALID_NETWORKS = new Set(['mainnet', 'testnet', 'devnet', 'local']); + function createChainClient(): KleverChainClient { - const network = (process.env.KLEVER_NETWORK as KleverNetwork) || 'mainnet'; + const envNetwork = process.env.KLEVER_NETWORK; + if (envNetwork && !VALID_NETWORKS.has(envNetwork)) { + console.error( + `[WARN] Invalid KLEVER_NETWORK="${envNetwork}". Valid: mainnet, testnet, devnet, local. Defaulting to mainnet.` + ); + } + const network: KleverNetwork = + envNetwork && VALID_NETWORKS.has(envNetwork) ? (envNetwork as KleverNetwork) : 'mainnet'; return new KleverChainClient({ network, nodeUrl: process.env.KLEVER_NODE_URL, diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 34a632c..ce83829 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -6,10 +6,15 @@ import { InMemoryStorage } from '../storage/memory.js'; import { KleverMCPServer } from './server.js'; import { KleverChainClient } from '../chain/index.js'; -// Mock global fetch for chain tools +// Mock global fetch for chain tools (save original and restore in afterAll) +const originalFetch = global.fetch; const mockFetch = jest.fn(); global.fetch = mockFetch; +afterAll(() => { + global.fetch = originalFetch; +}); + function jsonResponse(data: unknown, status = 200): Response { return { ok: status >= 200 && status < 300, @@ -645,6 +650,73 @@ describe('KleverMCPServer (local mode)', () => { expect(parsed.message).toContain('Unsigned'); }); + it('deploy_sc builds unsigned deploy transaction', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { nonce: 3 }, error: '', code: 'successful' }) + ); + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { result: { txHash: 'deployhash', tx: 'deploy_proto' } }, + error: '', + code: 'successful', + }) + ); + + const result = await client.callTool({ + name: 'deploy_sc', + arguments: { sender: 'klv1deployer', wasmHex: 'deadbeefcafe' }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.txHash).toBe('deployhash'); + expect(parsed.unsignedTx).toBe('deploy_proto'); + expect(parsed.details.sender).toBe('klv1deployer'); + expect(parsed.details.wasmSize).toBe('6 bytes'); + expect(parsed.details.nonce).toBe(3); + expect(parsed.nextSteps).toBeDefined(); + expect(parsed.message).toContain('deploy'); + }); + + it('invoke_sc builds unsigned invoke transaction', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { nonce: 7 }, error: '', code: 'successful' }) + ); + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { result: { txHash: 'invokehash', tx: 'invoke_proto' } }, + error: '', + code: 'successful', + }) + ); + + const result = await client.callTool({ + name: 'invoke_sc', + arguments: { + sender: 'klv1caller', + scAddress: 'klv1contract', + funcName: 'doSomething', + args: ['AQID'], + value: 1000000, + }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.txHash).toBe('invokehash'); + expect(parsed.unsignedTx).toBe('invoke_proto'); + expect(parsed.details.sender).toBe('klv1caller'); + expect(parsed.details.scAddress).toBe('klv1contract'); + expect(parsed.details.funcName).toBe('doSomething'); + expect(parsed.details.argsCount).toBe(1); + expect(parsed.details.value).toBe(1000000); + expect(parsed.details.nonce).toBe(7); + expect(parsed.nextSteps).toBeDefined(); + expect(parsed.message).toContain('invoke'); + }); + it('freeze_klv builds unsigned transaction', async () => { mockFetch.mockResolvedValueOnce( jsonResponse({ data: { nonce: 5 }, error: '', code: 'successful' }) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index fadf9bc..e485698 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -322,7 +322,7 @@ export class KleverMCPServer { description: 'Optional KDA token ID (e.g. "USDT-A1B2", "LPKLVKFI-3I0N"). Omit for KLV balance.', }, - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, required: ['address'], }, @@ -345,7 +345,7 @@ export class KleverMCPServer { type: 'string', description: 'Klever address (klv1... bech32 format).', }, - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, required: ['address'], }, @@ -369,7 +369,7 @@ export class KleverMCPServer { description: 'Asset identifier (e.g. "KLV", "KFI", "USDT-A1B2", "MYNFT-XY78").', }, - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, required: ['assetId'], }, @@ -403,7 +403,7 @@ export class KleverMCPServer { description: 'Optional base64-encoded arguments. For addresses, encode the hex-decoded bech32 bytes. For numbers, use big-endian byte encoding.', }, - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, required: ['scAddress', 'funcName'], }, @@ -426,7 +426,7 @@ export class KleverMCPServer { type: 'string', description: 'Transaction hash (hex string).', }, - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, required: ['hash'], }, @@ -451,7 +451,7 @@ export class KleverMCPServer { description: 'Block number (nonce). Omit to get the latest block.', }, - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, }, annotations: { @@ -469,7 +469,7 @@ export class KleverMCPServer { inputSchema: { type: 'object' as const, properties: { - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, }, annotations: { @@ -512,7 +512,7 @@ export class KleverMCPServer { description: 'Optional KDA token ID for non-KLV transfers (e.g. "USDT-A1B2"). Omit for KLV.', }, - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, required: ['sender', 'receiver', 'amount'], }, @@ -546,7 +546,7 @@ export class KleverMCPServer { description: 'Optional base64-encoded init arguments for the contract constructor.', }, - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, required: ['sender', 'wasmHex'], }, @@ -588,7 +588,7 @@ export class KleverMCPServer { description: 'Optional KLV amount to send with the call (smallest unit). Required for payable endpoints.', }, - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, required: ['sender', 'scAddress', 'funcName'], }, @@ -617,7 +617,7 @@ export class KleverMCPServer { description: 'Amount of KLV to freeze in the smallest unit (1 KLV = 1,000,000 units).', }, - network: { type: 'string', description: networkDesc }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, required: ['sender', 'amount'], }, @@ -768,8 +768,13 @@ export class KleverMCPServer { this.server.setRequestHandler(CallToolRequestSchema, async request => { const { name, arguments: args } = request.params; - // Debug logging to stderr - console.error(`[MCP] Tool called: ${name}`, JSON.stringify(args)); + // Debug logging to stderr (truncate large fields like wasmHex) + const safeArgs = args ? Object.fromEntries( + Object.entries(args).map(([k, v]) => + typeof v === 'string' && v.length > 200 ? [k, `${v.slice(0, 100)}...(${v.length} chars)`] : [k, v] + ) + ) : args; + console.error(`[MCP] Tool called: ${name}`, JSON.stringify(safeArgs)); // Block local-only tools in public profile const localOnlyTools = [ From af25cef31f9fab7712c93d0a66edf0e2ce484dac Mon Sep 17 00:00:00 2001 From: Fernando Sobreira Date: Sun, 15 Feb 2026 11:24:45 -0500 Subject: [PATCH 5/7] fix: address second round of PR review feedback - Set idempotentHint to false on chain write tools (nonce-dependent) - Fix getKDAInfo test mock to match KDAData interface shape --- src/chain/client.test.ts | 14 ++++++++++---- src/mcp/server.ts | 8 ++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/chain/client.test.ts b/src/chain/client.test.ts index 22d831b..2d38ad0 100644 --- a/src/chain/client.test.ts +++ b/src/chain/client.test.ts @@ -195,9 +195,13 @@ describe('KleverChainClient', () => { describe('getKDAInfo', () => { it('fetches KDA token info for an address', async () => { const kdaData = { - balance: 500000, - frozenBalance: 0, - lastClaim: { timestamp: 0, epoch: 0 }, + address: 'klv1test', + asset: 'USDT-A1B2', + userKDA: { + balance: 500000, + frozenBalance: 0, + LastClaim: { timestamp: 0, epoch: 0 }, + }, }; mockFetch.mockResolvedValueOnce( @@ -205,7 +209,9 @@ describe('KleverChainClient', () => { ); const result = await client.getKDAInfo('klv1test', 'USDT-A1B2'); - expect(result.balance).toBe(500000); + expect(result.address).toBe('klv1test'); + expect(result.asset).toBe('USDT-A1B2'); + expect(result.userKDA.balance).toBe(500000); expect(mockFetch).toHaveBeenCalledWith( 'https://node.testnet.klever.org/address/klv1test/kda?asset=USDT-A1B2', expect.anything() diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e485698..01c136b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -520,7 +520,7 @@ export class KleverMCPServer { title: 'Build Transfer Transaction', readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: true, }, }, @@ -554,7 +554,7 @@ export class KleverMCPServer { title: 'Build Deploy SC Transaction', readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: true, }, }, @@ -596,7 +596,7 @@ export class KleverMCPServer { title: 'Build Invoke SC Transaction', readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: true, }, }, @@ -625,7 +625,7 @@ export class KleverMCPServer { title: 'Build Freeze KLV Transaction', readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: true, }, }, From cbe52e2ad9a0a9ef6bc209caa3b3863d26b05643 Mon Sep 17 00:00:00 2001 From: Fernando Sobreira Date: Mon, 16 Feb 2026 11:55:17 -0500 Subject: [PATCH 6/7] fix: address reviewer feedback from nickgs1337 and RomuloSiebra - Fix SmartContract type from 9 to 63 (transaction.proto) - Fix SC sub-types: deploy=scType:1, invoke=scType:0 (were swapped) - Fix callValue format to map of token ID to amount ({KLV: amount}) - Fix Freeze type from 2 to 4 (FreezeContractType) - Fix TransactionBuildRequest to use flat contracts array - Add builder methods to KleverChainClient (buildTransfer, buildDeploy, buildInvoke, buildFreeze) to move chain logic out of server.ts - Extract fetchWithTimeout to reduce duplication in fetchJson/postJson - Add wasmPath option to deploy_sc (reads file server-side) - Add caller field to query_sc and VMQueryRequest - Add log() alias for console.error with comment explaining MCP stderr --- src/chain/client.test.ts | 150 ++++++++++++++++++++++- src/chain/client.ts | 143 ++++++++++++++++------ src/chain/index.ts | 8 ++ src/chain/types.ts | 55 ++++++++- src/index.ts | 1 + src/mcp/server.test.ts | 18 ++- src/mcp/server.ts | 249 ++++++++++++++++++--------------------- 7 files changed, 447 insertions(+), 177 deletions(-) diff --git a/src/chain/client.test.ts b/src/chain/client.test.ts index 2d38ad0..d934eb6 100644 --- a/src/chain/client.test.ts +++ b/src/chain/client.test.ts @@ -407,7 +407,7 @@ describe('KleverChainClient', () => { type: 0, sender: 'klv1sender', nonce: 5, - contract: [{ type: 0, parameter: { amount: 1000000, toAddress: 'klv1receiver' } }], + contracts: [{ amount: 1000000, toAddress: 'klv1receiver' }], }); expect(result.result.txHash).toBe('hash123'); @@ -415,6 +415,154 @@ describe('KleverChainClient', () => { }); }); + describe('querySmartContract with caller', () => { + it('passes caller field in VM query', async () => { + const vmResult = { + returnData: ['AQID'], + returnCode: 'ok', + returnMessage: '', + }; + + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: vmResult, error: '', code: 'successful' }) + ); + + await client.querySmartContract({ + scAddress: 'klv1contract', + funcName: 'getBalance', + caller: 'klv1caller', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://node.testnet.klever.org/vm/query', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + scAddress: 'klv1contract', + funcName: 'getBalance', + caller: 'klv1caller', + }), + }) + ); + }); + }); + + describe('buildTransfer', () => { + it('fetches nonce and builds a transfer transaction', async () => { + // Mock getNonce + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { nonce: 10 }, error: '', code: 'successful' }) + ); + // Mock buildTransaction + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { result: { txHash: 'hash1', tx: 'proto1' } }, + error: '', + code: 'successful', + }) + ); + + const result = await client.buildTransfer({ + sender: 'klv1sender', + receiver: 'klv1receiver', + amount: 5000000, + }); + + expect(result.result.txHash).toBe('hash1'); + // Verify the buildTransaction POST body + const postCall = mockFetch.mock.calls[1]; + const body = JSON.parse(postCall[1]?.body as string); + expect(body.type).toBe(0); + expect(body.contracts[0].amount).toBe(5000000); + expect(body.contracts[0].toAddress).toBe('klv1receiver'); + }); + }); + + describe('buildDeploy', () => { + it('builds a deploy transaction with correct type 63 and scType 1', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { nonce: 3 }, error: '', code: 'successful' }) + ); + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { result: { txHash: 'deploy1', tx: 'deploy_proto' } }, + error: '', + code: 'successful', + }) + ); + + const result = await client.buildDeploy({ + sender: 'klv1deployer', + wasmHex: 'deadbeef', + }); + + expect(result.result.txHash).toBe('deploy1'); + const postCall = mockFetch.mock.calls[1]; + const body = JSON.parse(postCall[1]?.body as string); + expect(body.type).toBe(63); + expect(body.contracts[0].scType).toBe(1); + expect(body.data).toEqual(['deadbeef']); + }); + }); + + describe('buildInvoke', () => { + it('builds an invoke transaction with correct type 63, scType 0, and callValue map', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { nonce: 7 }, error: '', code: 'successful' }) + ); + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { result: { txHash: 'invoke1', tx: 'invoke_proto' } }, + error: '', + code: 'successful', + }) + ); + + const result = await client.buildInvoke({ + sender: 'klv1caller', + scAddress: 'klv1contract', + funcName: 'doSomething', + args: ['AQID'], + callValue: { KLV: 1000000 }, + }); + + expect(result.result.txHash).toBe('invoke1'); + const postCall = mockFetch.mock.calls[1]; + const body = JSON.parse(postCall[1]?.body as string); + expect(body.type).toBe(63); + expect(body.contracts[0].scType).toBe(0); + expect(body.contracts[0].address).toBe('klv1contract'); + expect(body.contracts[0].callValue).toEqual({ KLV: 1000000 }); + expect(body.data).toEqual(['doSomething', 'AQID']); + }); + }); + + describe('buildFreeze', () => { + it('builds a freeze transaction with correct type 4', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { nonce: 5 }, error: '', code: 'successful' }) + ); + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: { result: { txHash: 'freeze1', tx: 'freeze_proto' } }, + error: '', + code: 'successful', + }) + ); + + const result = await client.buildFreeze({ + sender: 'klv1sender', + amount: 10000000, + }); + + expect(result.result.txHash).toBe('freeze1'); + const postCall = mockFetch.mock.calls[1]; + const body = JSON.parse(postCall[1]?.body as string); + expect(body.type).toBe(4); + expect(body.contracts[0].amount).toBe(10000000); + }); + }); + describe('getNodeStatus', () => { it('fetches node health status', async () => { const statusData = { diff --git a/src/chain/client.ts b/src/chain/client.ts index f15901c..9bd7682 100644 --- a/src/chain/client.ts +++ b/src/chain/client.ts @@ -5,6 +5,10 @@ * Uses native fetch (Node 18+) — no external HTTP dependencies. */ +import { + ContractType, + SCType, +} from './types.js'; import type { KleverNetwork, NetworkConfig, @@ -21,6 +25,10 @@ import type { NodeStatusData, TransactionBuildRequest, TransactionBuildData, + TransferParams, + DeployParams, + InvokeParams, + FreezeParams, } from './types.js'; /** Network URL mapping */ @@ -86,22 +94,19 @@ export class KleverChainClient { // ─── Core HTTP Methods ─────────────────────────────────── - private async fetchJson(url: string): Promise { + private async fetchWithTimeout(url: string, init?: RequestInit): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { - const response = await fetch(url, { - signal: controller.signal, - headers: { Accept: 'application/json' }, - }); + const response = await fetch(url, { ...init, signal: controller.signal }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(`HTTP ${response.status}: ${text || response.statusText}`); } - return (await response.json()) as T; + return response; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Request timed out after ${this.timeout}ms: ${url}`); @@ -112,35 +117,23 @@ export class KleverChainClient { } } - private async postJson(url: string, body: unknown): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); - - try { - const response = await fetch(url, { - method: 'POST', - signal: controller.signal, - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`HTTP ${response.status}: ${text || response.statusText}`); - } + private async fetchJson(url: string): Promise { + const response = await this.fetchWithTimeout(url, { + headers: { Accept: 'application/json' }, + }); + return (await response.json()) as T; + } - return (await response.json()) as T; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Request timed out after ${this.timeout}ms: ${url}`); - } - throw error; - } finally { - clearTimeout(timeoutId); - } + private async postJson(url: string, body: unknown): Promise { + const response = await this.fetchWithTimeout(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + return (await response.json()) as T; } /** Unwrap a Klever API response, throwing on error */ @@ -235,6 +228,88 @@ export class KleverChainClient { return this.unwrap(response, `querySmartContract(${request.scAddress}::${request.funcName})`); } + // ─── Transaction Builder Methods ──────────────────────── + + /** Build an unsigned transfer (KLV or KDA) transaction */ + async buildTransfer( + params: TransferParams, + network?: KleverNetwork + ): Promise { + const nonce = await this.getNonce(params.sender, network); + + const contracts: Array> = [ + { + amount: params.amount, + toAddress: params.receiver, + ...(params.assetId ? { assetId: params.assetId } : {}), + }, + ]; + + return this.buildTransaction( + { type: ContractType.Transfer, sender: params.sender, nonce, contracts }, + network + ); + } + + /** Build an unsigned smart contract deploy transaction */ + async buildDeploy( + params: DeployParams, + network?: KleverNetwork + ): Promise { + const nonce = await this.getNonce(params.sender, network); + + const data = [params.wasmHex, ...(params.initArgs || [])]; + + const contracts: Array> = [ + { scType: SCType.SCDeploy }, + ]; + + return this.buildTransaction( + { type: ContractType.SmartContract, sender: params.sender, nonce, contracts, data }, + network + ); + } + + /** Build an unsigned smart contract invoke transaction */ + async buildInvoke( + params: InvokeParams, + network?: KleverNetwork + ): Promise { + const nonce = await this.getNonce(params.sender, network); + + const data = [params.funcName, ...(params.args || [])]; + + const contracts: Array> = [ + { + scType: SCType.SCInvoke, + address: params.scAddress, + ...(params.callValue ? { callValue: params.callValue } : {}), + }, + ]; + + return this.buildTransaction( + { type: ContractType.SmartContract, sender: params.sender, nonce, contracts, data }, + network + ); + } + + /** Build an unsigned freeze KLV transaction */ + async buildFreeze( + params: FreezeParams, + network?: KleverNetwork + ): Promise { + const nonce = await this.getNonce(params.sender, network); + + const contracts: Array> = [ + { amount: params.amount }, + ]; + + return this.buildTransaction( + { type: ContractType.Freeze, sender: params.sender, nonce, contracts }, + network + ); + } + // ─── Transaction Operations ────────────────────────────── /** Get transaction details by hash (from node) */ diff --git a/src/chain/index.ts b/src/chain/index.ts index 3afcff2..dd2367c 100644 --- a/src/chain/index.ts +++ b/src/chain/index.ts @@ -1,5 +1,9 @@ export { KleverChainClient, NETWORK_CONFIGS } from './client.js'; export type { ChainClientOptions } from './client.js'; +export { + ContractType, + SCType, +} from './types.js'; export type { KleverNetwork, NetworkConfig, @@ -16,4 +20,8 @@ export type { NodeStatusData, TransactionBuildRequest, TransactionBuildData, + TransferParams, + DeployParams, + InvokeParams, + FreezeParams, } from './types.js'; diff --git a/src/chain/types.ts b/src/chain/types.ts index b88bee8..2e635dd 100644 --- a/src/chain/types.ts +++ b/src/chain/types.ts @@ -108,6 +108,7 @@ export interface VMQueryRequest { scAddress: string; funcName: string; args?: string[]; + caller?: string; } /** VM query response from POST /vm/query */ @@ -176,15 +177,31 @@ export interface NodeStatusData { [key: string]: unknown; } +/** + * Klever contract type numbers (from transaction.proto). + * + * TransferContractType = 0 + * FreezeContractType = 4 + * SmartContractType = 63 + */ +export const ContractType = { + Transfer: 0, + Freeze: 4, + SmartContract: 63, +} as const; + +/** SmartContract sub-types (from contracts.proto SCType enum) */ +export const SCType = { + SCInvoke: 0, + SCDeploy: 1, +} as const; + /** Transaction build request for POST /transaction/send */ export interface TransactionBuildRequest { type: number; sender: string; nonce: number; - contract: Array<{ - type: number; - parameter: Record; - }>; + contracts: Array>; data?: string[]; kdaFee?: { kda: string; @@ -192,6 +209,36 @@ export interface TransactionBuildRequest { }; } +/** Parameters for building a transfer transaction */ +export interface TransferParams { + sender: string; + receiver: string; + amount: number; + assetId?: string; +} + +/** Parameters for building a deploy SC transaction */ +export interface DeployParams { + sender: string; + wasmHex: string; + initArgs?: string[]; +} + +/** Parameters for building an invoke SC transaction */ +export interface InvokeParams { + sender: string; + scAddress: string; + funcName: string; + args?: string[]; + callValue?: Record; +} + +/** Parameters for building a freeze KLV transaction */ +export interface FreezeParams { + sender: string; + amount: number; +} + /** Transaction build response from POST /transaction/send */ export interface TransactionBuildData { result: { diff --git a/src/index.ts b/src/index.ts index 0f082e8..9b9db10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ const VALID_NETWORKS = new Set(['mainnet', 'testnet', 'devnet', 'local']); function createChainClient(): KleverChainClient { const envNetwork = process.env.KLEVER_NETWORK; if (envNetwork && !VALID_NETWORKS.has(envNetwork)) { + // In MCP mode stdout is reserved for the JSON-RPC protocol; stderr is the only safe log channel. console.error( `[WARN] Invalid KLEVER_NETWORK="${envNetwork}". Valid: mainnet, testnet, devnet, local. Defaulting to mainnet.` ); diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index ce83829..47ce8dc 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -674,11 +674,22 @@ describe('KleverMCPServer (local mode)', () => { expect(parsed.unsignedTx).toBe('deploy_proto'); expect(parsed.details.sender).toBe('klv1deployer'); expect(parsed.details.wasmSize).toBe('6 bytes'); - expect(parsed.details.nonce).toBe(3); expect(parsed.nextSteps).toBeDefined(); expect(parsed.message).toContain('deploy'); }); + it('deploy_sc requires wasmPath or wasmHex', async () => { + const result = await client.callTool({ + name: 'deploy_sc', + arguments: { sender: 'klv1deployer' }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error).toContain('wasmPath or wasmHex'); + }); + it('invoke_sc builds unsigned invoke transaction', async () => { mockFetch.mockResolvedValueOnce( jsonResponse({ data: { nonce: 7 }, error: '', code: 'successful' }) @@ -698,7 +709,7 @@ describe('KleverMCPServer (local mode)', () => { scAddress: 'klv1contract', funcName: 'doSomething', args: ['AQID'], - value: 1000000, + callValue: { KLV: 1000000 }, }, }); @@ -711,8 +722,7 @@ describe('KleverMCPServer (local mode)', () => { expect(parsed.details.scAddress).toBe('klv1contract'); expect(parsed.details.funcName).toBe('doSomething'); expect(parsed.details.argsCount).toBe(1); - expect(parsed.details.value).toBe(1000000); - expect(parsed.details.nonce).toBe(7); + expect(parsed.details.callValue).toEqual({ KLV: 1000000 }); expect(parsed.nextSteps).toBeDefined(); expect(parsed.message).toContain('invoke'); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 01c136b..ee7181c 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { @@ -19,6 +20,13 @@ import type { KleverNetwork, VMQueryRequest } from '../chain/types.js'; export type ServerProfile = 'local' | 'public'; +/** + * In MCP mode, stdout is reserved for the JSON-RPC protocol (stdio transport). + * Any non-protocol output on stdout breaks the MCP client. All logging must go + * to stderr via console.error. This alias improves readability for info-level logs. + */ +const log = (...args: unknown[]) => console.error(...args); + const VALID_NETWORKS = new Set(['mainnet', 'testnet', 'devnet', 'local']); function validateNetwork(network: string | undefined): KleverNetwork | undefined { @@ -403,6 +411,11 @@ export class KleverMCPServer { description: 'Optional base64-encoded arguments. For addresses, encode the hex-decoded bech32 bytes. For numbers, use big-endian byte encoding.', }, + caller: { + type: 'string', + description: + 'Optional caller address (klv1... bech32 format). Some view functions use the caller to look up address-keyed storage mappers.', + }, network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, required: ['scAddress', 'funcName'], @@ -527,7 +540,7 @@ export class KleverMCPServer { { name: 'deploy_sc', description: - 'Build an unsigned smart contract deployment transaction for the Klever blockchain. Provide the WASM bytecode as a hex string. Returns the unsigned transaction for client-side signing. The MCP server NEVER handles private keys.', + 'Build an unsigned smart contract deployment transaction for the Klever blockchain. Provide either wasmPath (preferred — reads the file server-side) or wasmHex. Returns the unsigned transaction for client-side signing. The MCP server NEVER handles private keys.', inputSchema: { type: 'object' as const, properties: { @@ -535,10 +548,15 @@ export class KleverMCPServer { type: 'string', description: 'Deployer address (klv1... bech32 format).', }, + wasmPath: { + type: 'string', + description: + 'Absolute path to the compiled WASM file (preferred over wasmHex to avoid loading large binaries into AI context).', + }, wasmHex: { type: 'string', description: - 'Smart contract WASM bytecode as a hex-encoded string.', + 'Smart contract WASM bytecode as a hex-encoded string. Use wasmPath instead for large contracts.', }, initArgs: { type: 'array', @@ -548,7 +566,7 @@ export class KleverMCPServer { }, network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, - required: ['sender', 'wasmHex'], + required: ['sender'], }, annotations: { title: 'Build Deploy SC Transaction', @@ -582,11 +600,11 @@ export class KleverMCPServer { items: { type: 'string' }, description: 'Optional base64-encoded arguments.', }, - value: { - type: 'integer', - minimum: 0, + callValue: { + type: 'object', + additionalProperties: { type: 'integer' }, description: - 'Optional KLV amount to send with the call (smallest unit). Required for payable endpoints.', + 'Optional token amounts to send with the call, as a map of token ID to amount (e.g. {"KLV": 1000000}). Required for payable endpoints.', }, network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, }, @@ -774,7 +792,7 @@ export class KleverMCPServer { typeof v === 'string' && v.length > 200 ? [k, `${v.slice(0, 100)}...(${v.length} chars)`] : [k, v] ) ) : args; - console.error(`[MCP] Tool called: ${name}`, JSON.stringify(safeArgs)); + log(`[MCP] Tool called: ${name}`, JSON.stringify(safeArgs)); // Block local-only tools in public profile const localOnlyTools = [ @@ -828,7 +846,7 @@ export class KleverMCPServer { switch (name) { case 'query_context': { const params = QueryContextSchema.parse(args); - console.error(`[MCP] Query params:`, JSON.stringify(params)); + log(`[MCP] Query params:`, JSON.stringify(params)); const result = await this.contextService.query(params); console.error( `[MCP] Query returned ${result.results.length} out of ${result.total} total results` @@ -986,7 +1004,7 @@ export class KleverMCPServer { } } - console.error(`[MCP] Knowledge stats: ${stats.total} total contexts`); + log(`[MCP] Knowledge stats: ${stats.total} total contexts`); return { content: [ @@ -1008,11 +1026,11 @@ export class KleverMCPServer { case 'enhance_with_context': { const { query, autoInclude = true } = args as { query: string; autoInclude?: boolean }; - console.error(`[MCP] Enhancing query with context: "${query}"`); + log(`[MCP] Enhancing query with context: "${query}"`); // Extract keywords for better matching const keywords = this.extractKeywords(query); - console.error(`[MCP] Extracted keywords: ${keywords.join(', ')}`); + log(`[MCP] Extracted keywords: ${keywords.join(', ')}`); // Query for relevant contexts const searchQuery = keywords.join(' '); @@ -1022,7 +1040,7 @@ export class KleverMCPServer { offset: 0, }); - console.error(`[MCP] Found ${result.results.length} relevant contexts`); + log(`[MCP] Found ${result.results.length} relevant contexts`); // Format the enhanced response let enhancedResponse = `Query: "${query}"\n\n`; @@ -1069,7 +1087,7 @@ export class KleverMCPServer { case 'search_documentation': { const { query, category } = args as { query: string; category?: string }; - console.error(`[MCP] Documentation search: "${query}" (category: ${category || 'all'})`); + log(`[MCP] Documentation search: "${query}" (category: ${category || 'all'})`); const { CATEGORY_TAG_MAP } = await import('./resources.js'); @@ -1121,7 +1139,7 @@ export class KleverMCPServer { contractName?: string; }; const label = contractName || 'contract'; - console.error(`[MCP] Analyzing contract: ${label} (${sourceCode.length} chars)`); + log(`[MCP] Analyzing contract: ${label} (${sourceCode.length} chars)`); const findings = await this.analyzeContractSource(sourceCode); @@ -1204,17 +1222,17 @@ export class KleverMCPServer { cmdArgs.push('--no-move'); } - console.error(`[MCP] Running: ${scriptPath} ${cmdArgs.join(' ')}`); + log(`[MCP] Running: ${scriptPath} ${cmdArgs.join(' ')}`); try { - console.error(`[MCP] Current working directory: ${process.cwd()}`); + log(`[MCP] Current working directory: ${process.cwd()}`); // Check if script exists try { await access(scriptPath); - console.error(`[MCP] Script path exists: true`); + log(`[MCP] Script path exists: true`); } catch { - console.error(`[MCP] Script path exists: false`); + log(`[MCP] Script path exists: false`); } // Execute the script using execFile to safely handle paths with spaces @@ -1223,10 +1241,10 @@ export class KleverMCPServer { env: { ...process.env }, }); - console.error(`[MCP] Script stdout length: ${stdout.length}`); - console.error(`[MCP] Script output: ${stdout}`); + log(`[MCP] Script stdout length: ${stdout.length}`); + log(`[MCP] Script output: ${stdout}`); if (stderr) { - console.error(`[MCP] Script stderr: ${stderr}`); + log(`[MCP] Script stderr: ${stderr}`); } // Clean up temp script @@ -1237,7 +1255,7 @@ export class KleverMCPServer { '-c', 'ls -la scripts/ 2>/dev/null || echo "No scripts directory"', ]); - console.error(`[MCP] Scripts directory check: ${checkResult.stdout}`); + log(`[MCP] Scripts directory check: ${checkResult.stdout}`); return { content: [ @@ -1279,8 +1297,8 @@ export class KleverMCPServer { // Clean up temp script on error await unlink(scriptPath).catch(() => {}); - console.error(`[MCP] Project init error: ${err.message}`); - console.error(`[MCP] Error details:`, err); + log(`[MCP] Project init error: ${err.message}`); + log(`[MCP] Error details:`, err); return { content: [ @@ -1338,7 +1356,7 @@ export class KleverMCPServer { const { createHelperScriptsScript } = await import('../utils/project-init-script.js'); const execFileHelper = promisifyUtil(execFileCb); - console.error(`[MCP] Adding helper scripts to existing project`); + log(`[MCP] Adding helper scripts to existing project`); // Create the helper scripts generation script const helperScriptContent = createHelperScriptsScript(); @@ -1348,10 +1366,10 @@ export class KleverMCPServer { await wf(helperScriptPath, helperScriptContent, 'utf8'); await ch(helperScriptPath, '755'); - console.error(`[MCP] Running: ${helperScriptPath}`); + log(`[MCP] Running: ${helperScriptPath}`); try { - console.error(`[MCP] Current working directory: ${process.cwd()}`); + log(`[MCP] Current working directory: ${process.cwd()}`); // Execute the script using execFile to safely handle paths with spaces const { stdout, stderr } = await execFileHelper('/bin/bash', [helperScriptPath], { @@ -1359,9 +1377,9 @@ export class KleverMCPServer { env: { ...process.env }, }); - console.error(`[MCP] Script stdout: ${stdout}`); + log(`[MCP] Script stdout: ${stdout}`); if (stderr) { - console.error(`[MCP] Script stderr: ${stderr}`); + log(`[MCP] Script stderr: ${stderr}`); } // Clean up temp script @@ -1372,7 +1390,7 @@ export class KleverMCPServer { '-c', 'ls -la scripts/ 2>/dev/null || echo "No scripts directory"', ]); - console.error(`[MCP] Scripts directory check: ${checkResult.stdout}`); + log(`[MCP] Scripts directory check: ${checkResult.stdout}`); return { content: [ @@ -1411,8 +1429,8 @@ export class KleverMCPServer { // Clean up temp script on error await ul(helperScriptPath).catch(() => {}); - console.error(`[MCP] Add helper scripts error: ${err.message}`); - console.error(`[MCP] Error details:`, err); + log(`[MCP] Add helper scripts error: ${err.message}`); + log(`[MCP] Error details:`, err); return { content: [ @@ -1446,7 +1464,7 @@ export class KleverMCPServer { const { createCheckSdkScript } = await import('../utils/sdk-install-script.js'); const execFileSdkAsync = promisifySdk(execFileSdk); - console.error(`[MCP] Checking SDK status`); + log(`[MCP] Checking SDK status`); const scriptContent = createCheckSdkScript(); const scriptPath = joinSdk(tdSdk(), `check-sdk-${Date.now()}.sh`); @@ -1460,13 +1478,13 @@ export class KleverMCPServer { }); if (stderr) { - console.error(`[MCP] Check SDK stderr: ${stderr}`); + log(`[MCP] Check SDK stderr: ${stderr}`); } await ulSdk(scriptPath); const status = JSON.parse(stdout.trim()); - console.error(`[MCP] SDK status: ksc=${status.ksc?.installed}, koperator=${status.koperator?.installed}`); + log(`[MCP] SDK status: ksc=${status.ksc?.installed}, koperator=${status.koperator?.installed}`); return { content: [ @@ -1487,7 +1505,7 @@ export class KleverMCPServer { const err = toExecError(error); await ulSdk(scriptPath).catch(() => {}); - console.error(`[MCP] Check SDK error: ${err.message}`); + log(`[MCP] Check SDK error: ${err.message}`); return { content: [ @@ -1521,7 +1539,7 @@ export class KleverMCPServer { const { tool: toolArg = 'all' } = args as { tool?: string }; const toolChoice = ['ksc', 'koperator', 'all'].includes(toolArg) ? toolArg : 'all'; - console.error(`[MCP] Installing SDK: ${toolChoice}`); + log(`[MCP] Installing SDK: ${toolChoice}`); const scriptContent = createInstallSdkScript(toolChoice); const scriptPath = joinInst(tdInst(), `install-sdk-${Date.now()}.sh`); @@ -1536,13 +1554,13 @@ export class KleverMCPServer { }); if (stderr) { - console.error(`[MCP] Install SDK stderr: ${stderr}`); + log(`[MCP] Install SDK stderr: ${stderr}`); } await ulInst(scriptPath); const result = JSON.parse(stdout.trim()); - console.error(`[MCP] Install SDK result: ${JSON.stringify(result)}`); + log(`[MCP] Install SDK result: ${JSON.stringify(result)}`); return { content: [ @@ -1563,7 +1581,7 @@ export class KleverMCPServer { const err = toExecError(error); await ulInst(scriptPath).catch(() => {}); - console.error(`[MCP] Install SDK error: ${err.message}`); + log(`[MCP] Install SDK error: ${err.message}`); return { content: [ @@ -1595,7 +1613,7 @@ export class KleverMCPServer { assetId?: string; network?: string; }; - console.error(`[MCP] get_balance: ${address} asset=${assetId || 'KLV'} network=${network || 'default'}`); + log(`[MCP] get_balance: ${address} asset=${assetId || 'KLV'} network=${network || 'default'}`); const balance = await this.chainClient.getBalance( address, @@ -1631,7 +1649,7 @@ export class KleverMCPServer { address: string; network?: string; }; - console.error(`[MCP] get_account: ${address} network=${network || 'default'}`); + log(`[MCP] get_account: ${address} network=${network || 'default'}`); const account = await this.chainClient.getAccount( address, @@ -1661,7 +1679,7 @@ export class KleverMCPServer { assetId: string; network?: string; }; - console.error(`[MCP] get_asset_info: ${assetId} network=${network || 'default'}`); + log(`[MCP] get_asset_info: ${assetId} network=${network || 'default'}`); const asset = await this.chainClient.getAssetInfo( assetId, @@ -1687,18 +1705,20 @@ export class KleverMCPServer { } case 'query_sc': { - const { scAddress, funcName, args: scArgs, network } = args as { + const { scAddress, funcName, args: scArgs, caller, network } = args as { scAddress: string; funcName: string; args?: string[]; + caller?: string; network?: string; }; - console.error(`[MCP] query_sc: ${scAddress}::${funcName} network=${network || 'default'}`); + log(`[MCP] query_sc: ${scAddress}::${funcName} network=${network || 'default'}`); const request: VMQueryRequest = { scAddress, funcName, args: scArgs, + ...(caller ? { caller } : {}), }; const result = await this.chainClient.querySmartContract( @@ -1730,7 +1750,7 @@ export class KleverMCPServer { hash: string; network?: string; }; - console.error(`[MCP] get_transaction: ${hash} network=${network || 'default'}`); + log(`[MCP] get_transaction: ${hash} network=${network || 'default'}`); const tx = await this.chainClient.getTransaction( hash, @@ -1760,7 +1780,7 @@ export class KleverMCPServer { nonce?: number; network?: string; }; - console.error(`[MCP] get_block: nonce=${nonce ?? 'latest'} network=${network || 'default'}`); + log(`[MCP] get_block: nonce=${nonce ?? 'latest'} network=${network || 'default'}`); const block = await this.chainClient.getBlock( nonce, @@ -1787,7 +1807,7 @@ export class KleverMCPServer { case 'list_validators': { const { network } = args as { network?: string }; - console.error(`[MCP] list_validators: network=${network || 'default'}`); + log(`[MCP] list_validators: network=${network || 'default'}`); const validators = await this.chainClient.listValidators( validateNetwork(network) @@ -1822,26 +1842,10 @@ export class KleverMCPServer { assetId?: string; network?: string; }; - console.error(`[MCP] send_transfer: ${sender} -> ${receiver} amount=${amount} asset=${assetId || 'KLV'}`); + log(`[MCP] send_transfer: ${sender} -> ${receiver} amount=${amount} asset=${assetId || 'KLV'}`); - const nonce = await this.chainClient.getNonce( - sender, - validateNetwork(network) - ); - - const contract: Array<{ type: number; parameter: Record }> = [ - { - type: 0, // Transfer - parameter: { - amount, - toAddress: receiver, - ...(assetId ? { assetId } : {}), - }, - }, - ]; - - const txResult = await this.chainClient.buildTransaction( - { type: 0, sender, nonce, contract }, + const txResult = await this.chainClient.buildTransfer( + { sender, receiver, amount, assetId }, validateNetwork(network) ); @@ -1861,7 +1865,6 @@ export class KleverMCPServer { receiver, amount, assetId: assetId || 'KLV', - nonce, }, network: network || this.chainClient.getDefaultNetwork(), nextSteps: [ @@ -1879,32 +1882,44 @@ export class KleverMCPServer { } case 'deploy_sc': { - const { sender, wasmHex, initArgs, network } = args as { + const { sender, wasmPath, wasmHex, initArgs, network } = args as { sender: string; - wasmHex: string; + wasmPath?: string; + wasmHex?: string; initArgs?: string[]; network?: string; }; - console.error(`[MCP] deploy_sc: sender=${sender} wasmSize=${wasmHex.length / 2} bytes`); - - const nonce = await this.chainClient.getNonce( - sender, - validateNetwork(network) - ); - const data = [wasmHex, ...(initArgs || [])]; + let resolvedWasmHex: string; + if (wasmPath) { + const wasmBuffer = await readFile(wasmPath); + resolvedWasmHex = wasmBuffer.toString('hex'); + } else if (wasmHex) { + resolvedWasmHex = wasmHex; + } else { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: 'Either wasmPath or wasmHex must be provided.', + suggestion: + 'Use wasmPath to specify the path to the compiled .wasm file (preferred), or wasmHex to provide the hex-encoded bytecode directly.', + }, + null, + 2 + ), + }, + ], + }; + } - const contract: Array<{ type: number; parameter: Record }> = [ - { - type: 9, // SmartContract (Deploy) - parameter: { - type: 0, // Deploy - }, - }, - ]; + log(`[MCP] deploy_sc: sender=${sender} wasmSize=${resolvedWasmHex.length / 2} bytes`); - const txResult = await this.chainClient.buildTransaction( - { type: 9, sender, nonce, contract, data }, + const txResult = await this.chainClient.buildDeploy( + { sender, wasmHex: resolvedWasmHex, initArgs }, validateNetwork(network) ); @@ -1921,8 +1936,8 @@ export class KleverMCPServer { unsignedTx: txResult.result.tx, details: { sender, - wasmSize: `${wasmHex.length / 2} bytes`, - nonce, + wasmSize: `${resolvedWasmHex.length / 2} bytes`, + ...(wasmPath ? { wasmPath } : {}), }, network: network || this.chainClient.getDefaultNetwork(), nextSteps: [ @@ -1945,38 +1960,20 @@ export class KleverMCPServer { scAddress, funcName, args: scArgs, - value, + callValue, network, } = args as { sender: string; scAddress: string; funcName: string; args?: string[]; - value?: number; + callValue?: Record; network?: string; }; - console.error(`[MCP] invoke_sc: ${sender} -> ${scAddress}::${funcName}`); + log(`[MCP] invoke_sc: ${sender} -> ${scAddress}::${funcName}`); - const nonce = await this.chainClient.getNonce( - sender, - validateNetwork(network) - ); - - const data = [funcName, ...(scArgs || [])]; - - const contract: Array<{ type: number; parameter: Record }> = [ - { - type: 9, // SmartContract (Invoke) - parameter: { - type: 1, // Invoke - callValue: value ? { amount: value } : undefined, - address: scAddress, - }, - }, - ]; - - const txResult = await this.chainClient.buildTransaction( - { type: 9, sender, nonce, contract, data }, + const txResult = await this.chainClient.buildInvoke( + { sender, scAddress, funcName, args: scArgs, callValue }, validateNetwork(network) ); @@ -1996,8 +1993,7 @@ export class KleverMCPServer { scAddress, funcName, argsCount: scArgs?.length || 0, - value: value || 0, - nonce, + callValue: callValue || {}, }, network: network || this.chainClient.getDefaultNetwork(), nextSteps: [ @@ -2020,24 +2016,10 @@ export class KleverMCPServer { amount: number; network?: string; }; - console.error(`[MCP] freeze_klv: ${sender} amount=${amount}`); - - const nonce = await this.chainClient.getNonce( - sender, - validateNetwork(network) - ); - - const contract: Array<{ type: number; parameter: Record }> = [ - { - type: 2, // Freeze - parameter: { - amount, - }, - }, - ]; + log(`[MCP] freeze_klv: ${sender} amount=${amount}`); - const txResult = await this.chainClient.buildTransaction( - { type: 2, sender, nonce, contract }, + const txResult = await this.chainClient.buildFreeze( + { sender, amount }, validateNetwork(network) ); @@ -2056,7 +2038,6 @@ export class KleverMCPServer { sender, amount, formattedAmount: `${(amount / 1_000_000).toFixed(6)} KLV`, - nonce, }, network: network || this.chainClient.getDefaultNetwork(), nextSteps: [ @@ -2147,7 +2128,7 @@ export class KleverMCPServer { async connectTransport(transport: Transport) { await this.server.connect(transport); - console.error(`[MCP] Klever MCP Server connected (profile: ${this.profile})`); + log(`[MCP] Klever MCP Server connected (profile: ${this.profile})`); } async start() { From 98e231692dfbdc55111154eb6197dadc81eee6bb Mon Sep 17 00:00:00 2001 From: Fernando Sobreira Date: Mon, 16 Feb 2026 21:08:56 -0500 Subject: [PATCH 7/7] fix: add wasmPath safety guards and fix getTransaction doc comment - Validate .wasm extension and enforce 10 MB max file size for wasmPath - Fix misleading JSDoc on getTransaction (uses API proxy, not node) --- src/chain/client.ts | 2 +- src/mcp/server.ts | 43 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/chain/client.ts b/src/chain/client.ts index 9bd7682..725b98b 100644 --- a/src/chain/client.ts +++ b/src/chain/client.ts @@ -312,7 +312,7 @@ export class KleverChainClient { // ─── Transaction Operations ────────────────────────────── - /** Get transaction details by hash (from node) */ + /** Get transaction details by hash (uses API proxy for indexed data) */ async getTransaction( hash: string, network?: KleverNetwork diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ee7181c..062fd50 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,4 +1,5 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; +import { extname } from 'node:path'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { @@ -1890,8 +1891,48 @@ export class KleverMCPServer { network?: string; }; + const MAX_WASM_SIZE = 10 * 1024 * 1024; // 10 MB + let resolvedWasmHex: string; if (wasmPath) { + if (extname(wasmPath).toLowerCase() !== '.wasm') { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: 'wasmPath must point to a .wasm file.', + suggestion: 'Provide the path to a compiled WebAssembly binary (e.g. output/contract.wasm).', + }, + null, + 2 + ), + }, + ], + }; + } + + const fileInfo = await stat(wasmPath); + if (fileInfo.size > MAX_WASM_SIZE) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: `WASM file too large: ${(fileInfo.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_WASM_SIZE / 1024 / 1024} MB).`, + }, + null, + 2 + ), + }, + ], + }; + } + const wasmBuffer = await readFile(wasmPath); resolvedWasmHex = wasmBuffer.toString('hex'); } else if (wasmHex) {