kaspa-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +148 -0
- package/dist/e2e.test.d.ts +1 -0
- package/dist/e2e.test.js +184 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +63 -0
- package/dist/integration.test.d.ts +1 -0
- package/dist/integration.test.js +159 -0
- package/dist/kaspa/api.d.ts +11 -0
- package/dist/kaspa/api.js +49 -0
- package/dist/kaspa/api.test.d.ts +1 -0
- package/dist/kaspa/api.test.js +177 -0
- package/dist/kaspa/setup.d.ts +1 -0
- package/dist/kaspa/setup.js +4 -0
- package/dist/kaspa/transaction.d.ts +5 -0
- package/dist/kaspa/transaction.js +60 -0
- package/dist/kaspa/transaction.test.d.ts +1 -0
- package/dist/kaspa/transaction.test.js +170 -0
- package/dist/kaspa/wallet.d.ts +15 -0
- package/dist/kaspa/wallet.js +97 -0
- package/dist/kaspa/wallet.test.d.ts +1 -0
- package/dist/kaspa/wallet.test.js +160 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +4 -0
- package/dist/tools/get-balance.d.ts +9 -0
- package/dist/tools/get-balance.js +21 -0
- package/dist/tools/get-balance.test.d.ts +1 -0
- package/dist/tools/get-balance.test.js +93 -0
- package/dist/tools/get-fee-estimate.d.ts +6 -0
- package/dist/tools/get-fee-estimate.js +13 -0
- package/dist/tools/get-fee-estimate.test.d.ts +1 -0
- package/dist/tools/get-fee-estimate.test.js +74 -0
- package/dist/tools/get-my-address.d.ts +4 -0
- package/dist/tools/get-my-address.js +9 -0
- package/dist/tools/get-my-address.test.d.ts +1 -0
- package/dist/tools/get-my-address.test.js +32 -0
- package/dist/tools/get-transaction.d.ts +21 -0
- package/dist/tools/get-transaction.js +36 -0
- package/dist/tools/get-transaction.test.d.ts +1 -0
- package/dist/tools/get-transaction.test.js +119 -0
- package/dist/tools/send-kaspa.d.ts +10 -0
- package/dist/tools/send-kaspa.js +53 -0
- package/dist/tools/send-kaspa.test.d.ts +1 -0
- package/dist/tools/send-kaspa.test.js +147 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.js +3 -0
- package/package.json +39 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ABOUTME: MCP tool to send KAS tokens to a recipient address
|
|
2
|
+
// ABOUTME: Builds, signs, and broadcasts the transaction
|
|
3
|
+
import * as kaspa from 'kaspa-wasm';
|
|
4
|
+
import { sendKaspa as sendKaspaTransaction } from '../kaspa/transaction.js';
|
|
5
|
+
import { getWallet } from '../kaspa/wallet.js';
|
|
6
|
+
const { Address, NetworkType } = kaspa;
|
|
7
|
+
const SOMPI_PER_KAS = 100000000n;
|
|
8
|
+
const MAX_DECIMAL_PLACES = 8;
|
|
9
|
+
function validateAddress(address) {
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = new Address(address);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error(`Invalid Kaspa address: ${address}`);
|
|
16
|
+
}
|
|
17
|
+
const wallet = getWallet();
|
|
18
|
+
const walletNetwork = wallet.getNetworkType();
|
|
19
|
+
const expectedPrefix = walletNetwork === NetworkType.Mainnet ? 'kaspa' : 'kaspatest';
|
|
20
|
+
if (parsed.prefix !== expectedPrefix) {
|
|
21
|
+
throw new Error(`Address network mismatch: wallet is on ${wallet.getNetworkId()}, but address is for ${parsed.prefix === 'kaspa' ? 'mainnet' : 'testnet'}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function kasToSompi(amountStr) {
|
|
25
|
+
const trimmed = amountStr.trim();
|
|
26
|
+
if (!/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
27
|
+
throw new Error('Amount must be a valid decimal number');
|
|
28
|
+
}
|
|
29
|
+
const parts = trimmed.split('.');
|
|
30
|
+
const integerPart = parts[0];
|
|
31
|
+
let fractionalPart = parts[1] || '';
|
|
32
|
+
if (fractionalPart.length > MAX_DECIMAL_PLACES) {
|
|
33
|
+
throw new Error(`Amount cannot have more than ${MAX_DECIMAL_PLACES} decimal places`);
|
|
34
|
+
}
|
|
35
|
+
fractionalPart = fractionalPart.padEnd(MAX_DECIMAL_PLACES, '0');
|
|
36
|
+
const sompi = BigInt(integerPart) * SOMPI_PER_KAS + BigInt(fractionalPart);
|
|
37
|
+
if (sompi <= 0n) {
|
|
38
|
+
throw new Error('Amount must be greater than zero');
|
|
39
|
+
}
|
|
40
|
+
return sompi;
|
|
41
|
+
}
|
|
42
|
+
export async function sendKaspa(params) {
|
|
43
|
+
if (!params.to) {
|
|
44
|
+
throw new Error('Recipient address (to) is required');
|
|
45
|
+
}
|
|
46
|
+
if (!params.amount) {
|
|
47
|
+
throw new Error('Amount is required');
|
|
48
|
+
}
|
|
49
|
+
validateAddress(params.to);
|
|
50
|
+
const amountSompi = kasToSompi(params.amount);
|
|
51
|
+
const result = await sendKaspaTransaction(params.to, amountSompi, BigInt(params.priorityFee || 0));
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// ABOUTME: Unit tests for send-kaspa MCP tool
|
|
2
|
+
// ABOUTME: Tests amount conversion, address validation, and transaction sending
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
vi.mock('kaspa-wasm', () => {
|
|
5
|
+
class MockAddress {
|
|
6
|
+
prefix;
|
|
7
|
+
constructor(addr) {
|
|
8
|
+
if (addr === 'invalid-address') {
|
|
9
|
+
throw new Error('Invalid address format');
|
|
10
|
+
}
|
|
11
|
+
this.prefix = addr.startsWith('kaspatest:') ? 'kaspatest' : 'kaspa';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
Address: MockAddress,
|
|
16
|
+
NetworkType: {
|
|
17
|
+
Mainnet: 0,
|
|
18
|
+
Testnet: 1,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
vi.mock('../kaspa/wallet.js', () => ({
|
|
23
|
+
getWallet: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('../kaspa/transaction.js', () => ({
|
|
26
|
+
sendKaspa: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
import { sendKaspa } from './send-kaspa.js';
|
|
29
|
+
import { getWallet } from '../kaspa/wallet.js';
|
|
30
|
+
import { sendKaspa as sendKaspaTransaction } from '../kaspa/transaction.js';
|
|
31
|
+
describe('sendKaspa', () => {
|
|
32
|
+
const mockWallet = {
|
|
33
|
+
getNetworkType: vi.fn(),
|
|
34
|
+
getNetworkId: vi.fn(),
|
|
35
|
+
};
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.mocked(getWallet).mockReturnValue(mockWallet);
|
|
38
|
+
mockWallet.getNetworkType.mockReset();
|
|
39
|
+
mockWallet.getNetworkId.mockReset();
|
|
40
|
+
vi.mocked(sendKaspaTransaction).mockReset();
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
describe('parameter validation', () => {
|
|
46
|
+
it('throws error when recipient address is not provided', async () => {
|
|
47
|
+
await expect(sendKaspa({ to: '', amount: '1' })).rejects.toThrow('Recipient address (to) is required');
|
|
48
|
+
});
|
|
49
|
+
it('throws error when amount is not provided', async () => {
|
|
50
|
+
await expect(sendKaspa({ to: 'kaspa:qptest', amount: '' })).rejects.toThrow('Amount is required');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('address validation', () => {
|
|
54
|
+
it('throws error for invalid address format', async () => {
|
|
55
|
+
mockWallet.getNetworkType.mockReturnValue(0);
|
|
56
|
+
mockWallet.getNetworkId.mockReturnValue('mainnet');
|
|
57
|
+
await expect(sendKaspa({ to: 'invalid-address', amount: '1' })).rejects.toThrow('Invalid Kaspa address: invalid-address');
|
|
58
|
+
});
|
|
59
|
+
it('throws error for network mismatch (mainnet wallet, testnet address)', async () => {
|
|
60
|
+
mockWallet.getNetworkType.mockReturnValue(0); // Mainnet
|
|
61
|
+
mockWallet.getNetworkId.mockReturnValue('mainnet');
|
|
62
|
+
await expect(sendKaspa({ to: 'kaspatest:qptest123', amount: '1' })).rejects.toThrow('Address network mismatch: wallet is on mainnet, but address is for testnet');
|
|
63
|
+
});
|
|
64
|
+
it('throws error for network mismatch (testnet wallet, mainnet address)', async () => {
|
|
65
|
+
mockWallet.getNetworkType.mockReturnValue(1); // Testnet
|
|
66
|
+
mockWallet.getNetworkId.mockReturnValue('testnet-10');
|
|
67
|
+
await expect(sendKaspa({ to: 'kaspa:qpmainnet456', amount: '1' })).rejects.toThrow('Address network mismatch: wallet is on testnet-10, but address is for mainnet');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('kasToSompi conversion', () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
mockWallet.getNetworkType.mockReturnValue(0);
|
|
73
|
+
mockWallet.getNetworkId.mockReturnValue('mainnet');
|
|
74
|
+
});
|
|
75
|
+
it('converts integer KAS to sompi', async () => {
|
|
76
|
+
vi.mocked(sendKaspaTransaction).mockResolvedValue({ txId: 'tx1', fee: '100' });
|
|
77
|
+
await sendKaspa({ to: 'kaspa:qptest', amount: '10' });
|
|
78
|
+
expect(sendKaspaTransaction).toHaveBeenCalledWith('kaspa:qptest', 1000000000n, 0n);
|
|
79
|
+
});
|
|
80
|
+
it('converts decimal KAS to sompi', async () => {
|
|
81
|
+
vi.mocked(sendKaspaTransaction).mockResolvedValue({ txId: 'tx2', fee: '100' });
|
|
82
|
+
await sendKaspa({ to: 'kaspa:qptest', amount: '1.5' });
|
|
83
|
+
expect(sendKaspaTransaction).toHaveBeenCalledWith('kaspa:qptest', 150000000n, 0n);
|
|
84
|
+
});
|
|
85
|
+
it('converts small decimal amounts', async () => {
|
|
86
|
+
vi.mocked(sendKaspaTransaction).mockResolvedValue({ txId: 'tx3', fee: '100' });
|
|
87
|
+
await sendKaspa({ to: 'kaspa:qptest', amount: '0.00000001' });
|
|
88
|
+
expect(sendKaspaTransaction).toHaveBeenCalledWith('kaspa:qptest', 1n, 0n);
|
|
89
|
+
});
|
|
90
|
+
it('handles max decimal places (8)', async () => {
|
|
91
|
+
vi.mocked(sendKaspaTransaction).mockResolvedValue({ txId: 'tx4', fee: '100' });
|
|
92
|
+
await sendKaspa({ to: 'kaspa:qptest', amount: '1.12345678' });
|
|
93
|
+
expect(sendKaspaTransaction).toHaveBeenCalledWith('kaspa:qptest', 112345678n, 0n);
|
|
94
|
+
});
|
|
95
|
+
it('throws error for more than 8 decimal places', async () => {
|
|
96
|
+
await expect(sendKaspa({ to: 'kaspa:qptest', amount: '1.123456789' })).rejects.toThrow('Amount cannot have more than 8 decimal places');
|
|
97
|
+
});
|
|
98
|
+
it('throws error for invalid amount format', async () => {
|
|
99
|
+
await expect(sendKaspa({ to: 'kaspa:qptest', amount: 'abc' })).rejects.toThrow('Amount must be a valid decimal number');
|
|
100
|
+
});
|
|
101
|
+
it('throws error for negative amount', async () => {
|
|
102
|
+
await expect(sendKaspa({ to: 'kaspa:qptest', amount: '-1' })).rejects.toThrow('Amount must be a valid decimal number');
|
|
103
|
+
});
|
|
104
|
+
it('throws error for zero amount', async () => {
|
|
105
|
+
await expect(sendKaspa({ to: 'kaspa:qptest', amount: '0' })).rejects.toThrow('Amount must be greater than zero');
|
|
106
|
+
});
|
|
107
|
+
it('handles whitespace in amount', async () => {
|
|
108
|
+
vi.mocked(sendKaspaTransaction).mockResolvedValue({ txId: 'tx5', fee: '100' });
|
|
109
|
+
await sendKaspa({ to: 'kaspa:qptest', amount: ' 5 ' });
|
|
110
|
+
expect(sendKaspaTransaction).toHaveBeenCalledWith('kaspa:qptest', 500000000n, 0n);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('transaction sending', () => {
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
mockWallet.getNetworkType.mockReturnValue(0);
|
|
116
|
+
mockWallet.getNetworkId.mockReturnValue('mainnet');
|
|
117
|
+
});
|
|
118
|
+
it('sends transaction and returns result', async () => {
|
|
119
|
+
vi.mocked(sendKaspaTransaction).mockResolvedValue({ txId: 'txabc123', fee: '250' });
|
|
120
|
+
const result = await sendKaspa({ to: 'kaspa:qptest', amount: '5' });
|
|
121
|
+
expect(result).toEqual({ txId: 'txabc123', fee: '250' });
|
|
122
|
+
});
|
|
123
|
+
it('passes priority fee when provided', async () => {
|
|
124
|
+
vi.mocked(sendKaspaTransaction).mockResolvedValue({ txId: 'txfee', fee: '500' });
|
|
125
|
+
await sendKaspa({ to: 'kaspa:qptest', amount: '5', priorityFee: 1000 });
|
|
126
|
+
expect(sendKaspaTransaction).toHaveBeenCalledWith('kaspa:qptest', 500000000n, 1000n);
|
|
127
|
+
});
|
|
128
|
+
it('uses zero priority fee when not provided', async () => {
|
|
129
|
+
vi.mocked(sendKaspaTransaction).mockResolvedValue({ txId: 'txnofee', fee: '100' });
|
|
130
|
+
await sendKaspa({ to: 'kaspa:qptest', amount: '1' });
|
|
131
|
+
expect(sendKaspaTransaction).toHaveBeenCalledWith('kaspa:qptest', 100000000n, 0n);
|
|
132
|
+
});
|
|
133
|
+
it('propagates transaction errors', async () => {
|
|
134
|
+
vi.mocked(sendKaspaTransaction).mockRejectedValue(new Error('Insufficient balance'));
|
|
135
|
+
await expect(sendKaspa({ to: 'kaspa:qptest', amount: '1000000' })).rejects.toThrow('Insufficient balance');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
describe('testnet transactions', () => {
|
|
139
|
+
it('sends to testnet address when wallet is on testnet', async () => {
|
|
140
|
+
mockWallet.getNetworkType.mockReturnValue(1); // Testnet
|
|
141
|
+
mockWallet.getNetworkId.mockReturnValue('testnet-10');
|
|
142
|
+
vi.mocked(sendKaspaTransaction).mockResolvedValue({ txId: 'testnettx', fee: '100' });
|
|
143
|
+
const result = await sendKaspa({ to: 'kaspatest:qptest', amount: '1' });
|
|
144
|
+
expect(result).toEqual({ txId: 'testnettx', fee: '100' });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface UtxoResponse {
|
|
2
|
+
address: string;
|
|
3
|
+
outpoint: {
|
|
4
|
+
transactionId: string;
|
|
5
|
+
index: number;
|
|
6
|
+
};
|
|
7
|
+
utxoEntry: {
|
|
8
|
+
amount: string;
|
|
9
|
+
scriptPublicKey: {
|
|
10
|
+
scriptPublicKey: string;
|
|
11
|
+
};
|
|
12
|
+
blockDaaScore: string;
|
|
13
|
+
isCoinbase: boolean;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export interface BalanceResponse {
|
|
17
|
+
address: string;
|
|
18
|
+
balance: string;
|
|
19
|
+
}
|
|
20
|
+
export interface FeeEstimateResponse {
|
|
21
|
+
priorityBucket: {
|
|
22
|
+
feerate: number;
|
|
23
|
+
estimatedSeconds: number;
|
|
24
|
+
};
|
|
25
|
+
normalBuckets: Array<{
|
|
26
|
+
feerate: number;
|
|
27
|
+
estimatedSeconds: number;
|
|
28
|
+
}>;
|
|
29
|
+
lowBuckets: Array<{
|
|
30
|
+
feerate: number;
|
|
31
|
+
estimatedSeconds: number;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
export interface TransactionResponse {
|
|
35
|
+
transaction_id: string;
|
|
36
|
+
block_hash: string[];
|
|
37
|
+
block_time: number;
|
|
38
|
+
is_accepted: boolean;
|
|
39
|
+
inputs: Array<{
|
|
40
|
+
transaction_id: string;
|
|
41
|
+
index: number;
|
|
42
|
+
previous_outpoint_hash: string;
|
|
43
|
+
previous_outpoint_index: number;
|
|
44
|
+
signature_script: string;
|
|
45
|
+
sig_op_count: number;
|
|
46
|
+
}>;
|
|
47
|
+
outputs: Array<{
|
|
48
|
+
amount: string;
|
|
49
|
+
script_public_key: string;
|
|
50
|
+
script_public_key_address: string;
|
|
51
|
+
script_public_key_type: string;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kaspa-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Kaspa transactions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"kaspa-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"dev": "tsc --watch",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"test:coverage": "vitest run --coverage"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "1.25.3",
|
|
24
|
+
"isomorphic-ws": "5.0.0",
|
|
25
|
+
"kaspa": "0.13.0",
|
|
26
|
+
"kaspa-wasm": "0.13.0",
|
|
27
|
+
"ws": "8.14.2",
|
|
28
|
+
"zod": "4.3.6"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "20.19.30",
|
|
32
|
+
"@vitest/coverage-v8": "4.0.18",
|
|
33
|
+
"typescript": "5.9.3",
|
|
34
|
+
"vitest": "4.0.18"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|