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..d934eb6 --- /dev/null +++ b/src/chain/client.test.ts @@ -0,0 +1,607 @@ +import { jest } from '@jest/globals'; +import { KleverChainClient, NETWORK_CONFIGS } from './client.js'; + +// 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, + 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('getKDAInfo', () => { + it('fetches KDA token info for an address', async () => { + const kdaData = { + address: 'klv1test', + asset: 'USDT-A1B2', + userKDA: { + 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.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() + ); + }); + + 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 = { + 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, + contracts: [{ amount: 1000000, toAddress: 'klv1receiver' }], + }); + + expect(result.result.txHash).toBe('hash123'); + expect(result.result.tx).toBe('proto_encoded_tx'); + }); + }); + + 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 = { + 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..725b98b --- /dev/null +++ b/src/chain/client.ts @@ -0,0 +1,409 @@ +/** + * 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 { + ContractType, + SCType, +} from './types.js'; +import type { + KleverNetwork, + NetworkConfig, + KleverResponse, + AccountData, + BalanceData, + AssetData, + KDAData, + VMQueryRequest, + VMQueryData, + TransactionData, + BlockData, + ValidatorData, + NodeStatusData, + TransactionBuildRequest, + TransactionBuildData, + TransferParams, + DeployParams, + InvokeParams, + FreezeParams, +} 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 fetchWithTimeout(url: string, init?: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + 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 response; + } 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 fetchJson(url: string): Promise { + const response = await this.fetchWithTimeout(url, { + headers: { Accept: 'application/json' }, + }); + return (await response.json()) as T; + } + + 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 */ + 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 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 (uses API proxy for indexed data) */ + 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..dd2367c --- /dev/null +++ b/src/chain/index.ts @@ -0,0 +1,27 @@ +export { KleverChainClient, NETWORK_CONFIGS } from './client.js'; +export type { ChainClientOptions } from './client.js'; +export { + ContractType, + SCType, +} from './types.js'; +export type { + KleverNetwork, + NetworkConfig, + KleverResponse, + AccountData, + BalanceData, + AssetData, + KDAData, + VMQueryRequest, + VMQueryData, + TransactionData, + BlockData, + ValidatorData, + NodeStatusData, + TransactionBuildRequest, + TransactionBuildData, + TransferParams, + DeployParams, + InvokeParams, + FreezeParams, +} from './types.js'; diff --git a/src/chain/types.ts b/src/chain/types.ts new file mode 100644 index 0000000..2e635dd --- /dev/null +++ b/src/chain/types.ts @@ -0,0 +1,290 @@ +/** + * 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[]; + caller?: 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; +} + +/** + * 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; + contracts: Array>; + data?: string[]; + kdaFee?: { + kda: string; + amount: number; + }; +} + +/** 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: { + 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..9b9db10 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,26 @@ function createStorageAndService() { return { storageType, contextService }; } +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.` + ); + } + const network: KleverNetwork = + envNetwork && VALID_NETWORKS.has(envNetwork) ? (envNetwork 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 +110,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 +245,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/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 diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 62f0d03..47ce8dc 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -4,6 +4,37 @@ 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 (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, + 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 +418,335 @@ 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('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' }) + ); + + 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('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.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' }) + ); + 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'], + callValue: { KLV: 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.callValue).toEqual({ KLV: 1000000 }); + 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' }) + ); + 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..062fd50 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,3 +1,5 @@ +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 { @@ -14,9 +16,30 @@ 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'; +/** + * 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 { + 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; @@ -38,12 +61,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 +312,345 @@ 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', enum: ['mainnet', 'testnet', 'devnet', 'local'], 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', enum: ['mainnet', 'testnet', 'devnet', 'local'], 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', enum: ['mainnet', 'testnet', 'devnet', 'local'], 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.', + }, + 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'], + }, + 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', enum: ['mainnet', 'testnet', 'devnet', 'local'], 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', enum: ['mainnet', 'testnet', 'devnet', 'local'], 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', enum: ['mainnet', 'testnet', 'devnet', 'local'], 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', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, + }, + required: ['sender', 'receiver', 'amount'], + }, + annotations: { + title: 'Build Transfer Transaction', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + { + name: 'deploy_sc', + description: + '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: { + sender: { + 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. Use wasmPath instead for large contracts.', + }, + initArgs: { + type: 'array', + items: { type: 'string' }, + description: + 'Optional base64-encoded init arguments for the contract constructor.', + }, + network: { type: 'string', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, + }, + required: ['sender'], + }, + annotations: { + title: 'Build Deploy SC Transaction', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + 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.', + }, + callValue: { + type: 'object', + additionalProperties: { type: 'integer' }, + description: + '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 }, + }, + required: ['sender', 'scAddress', 'funcName'], + }, + annotations: { + title: 'Build Invoke SC Transaction', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + 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', enum: ['mainnet', 'testnet', 'devnet', 'local'], description: networkDesc }, + }, + required: ['sender', 'amount'], + }, + annotations: { + title: 'Build Freeze KLV Transaction', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + ]; + } + private async getPublicModeToolDefinitions() { const { projectInitToolDefinition, addHelperScriptsToolDefinition } = await import( '../utils/project-init-script.js' @@ -401,12 +766,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); } @@ -418,14 +787,25 @@ 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; + log(`[MCP] Tool called: ${name}`, JSON.stringify(safeArgs)); // 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 +813,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 +824,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', ], @@ -460,7 +847,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` @@ -618,7 +1005,7 @@ export class KleverMCPServer { } } - console.error(`[MCP] Knowledge stats: ${stats.total} total contexts`); + log(`[MCP] Knowledge stats: ${stats.total} total contexts`); return { content: [ @@ -640,11 +1027,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(' '); @@ -654,7 +1041,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`; @@ -701,7 +1088,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'); @@ -753,7 +1140,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); @@ -836,17 +1223,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 @@ -855,10 +1242,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 @@ -869,7 +1256,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: [ @@ -911,8 +1298,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: [ @@ -970,7 +1357,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(); @@ -980,10 +1367,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], { @@ -991,9 +1378,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 @@ -1004,7 +1391,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: [ @@ -1043,8 +1430,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: [ @@ -1078,7 +1465,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`); @@ -1092,13 +1479,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: [ @@ -1119,7 +1506,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: [ @@ -1153,7 +1540,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`); @@ -1168,13 +1555,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: [ @@ -1195,7 +1582,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: [ @@ -1219,6 +1606,495 @@ export class KleverMCPServer { } } + // ─── Chain Read Tools ───────────────────────────── + + case 'get_balance': { + const { address, assetId, network } = args as { + address: string; + assetId?: string; + network?: string; + }; + log(`[MCP] get_balance: ${address} asset=${assetId || 'KLV'} network=${network || 'default'}`); + + const balance = await this.chainClient.getBalance( + address, + assetId, + validateNetwork(network) + ); + + 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; + }; + log(`[MCP] get_account: ${address} network=${network || 'default'}`); + + const account = await this.chainClient.getAccount( + address, + validateNetwork(network) + ); + + 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; + }; + log(`[MCP] get_asset_info: ${assetId} network=${network || 'default'}`); + + const asset = await this.chainClient.getAssetInfo( + assetId, + validateNetwork(network) + ); + + 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, caller, network } = args as { + scAddress: string; + funcName: string; + args?: string[]; + caller?: string; + network?: string; + }; + log(`[MCP] query_sc: ${scAddress}::${funcName} network=${network || 'default'}`); + + const request: VMQueryRequest = { + scAddress, + funcName, + args: scArgs, + ...(caller ? { caller } : {}), + }; + + const result = await this.chainClient.querySmartContract( + request, + validateNetwork(network) + ); + + 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; + }; + log(`[MCP] get_transaction: ${hash} network=${network || 'default'}`); + + const tx = await this.chainClient.getTransaction( + hash, + validateNetwork(network) + ); + + 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; + }; + log(`[MCP] get_block: nonce=${nonce ?? 'latest'} network=${network || 'default'}`); + + const block = await this.chainClient.getBlock( + nonce, + validateNetwork(network) + ); + + 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 }; + log(`[MCP] list_validators: network=${network || 'default'}`); + + const validators = await this.chainClient.listValidators( + validateNetwork(network) + ); + + 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; + }; + log(`[MCP] send_transfer: ${sender} -> ${receiver} amount=${amount} asset=${assetId || 'KLV'}`); + + const txResult = await this.chainClient.buildTransfer( + { sender, receiver, amount, assetId }, + validateNetwork(network) + ); + + 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', + }, + 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, wasmPath, wasmHex, initArgs, network } = args as { + sender: string; + wasmPath?: string; + wasmHex?: string; + initArgs?: string[]; + 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) { + 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 + ), + }, + ], + }; + } + + log(`[MCP] deploy_sc: sender=${sender} wasmSize=${resolvedWasmHex.length / 2} bytes`); + + const txResult = await this.chainClient.buildDeploy( + { sender, wasmHex: resolvedWasmHex, initArgs }, + validateNetwork(network) + ); + + 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: `${resolvedWasmHex.length / 2} bytes`, + ...(wasmPath ? { wasmPath } : {}), + }, + 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, + callValue, + network, + } = args as { + sender: string; + scAddress: string; + funcName: string; + args?: string[]; + callValue?: Record; + network?: string; + }; + log(`[MCP] invoke_sc: ${sender} -> ${scAddress}::${funcName}`); + + const txResult = await this.chainClient.buildInvoke( + { sender, scAddress, funcName, args: scArgs, callValue }, + validateNetwork(network) + ); + + 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, + callValue: callValue || {}, + }, + 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; + }; + log(`[MCP] freeze_klv: ${sender} amount=${amount}`); + + const txResult = await this.chainClient.buildFreeze( + { sender, amount }, + validateNetwork(network) + ); + + 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`, + }, + 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: [ @@ -1293,7 +2169,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() {