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
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Kaspa MCP
|
|
2
|
+
|
|
3
|
+
MCP server for sending KAS on the Kaspa blockDAG.
|
|
4
|
+
|
|
5
|
+
## About Kaspa
|
|
6
|
+
|
|
7
|
+
[Kaspa](https://kaspa.org) is a fast, scalable Layer-1 cryptocurrency built on proof-of-work (PoW) and powered by the GHOSTDAG protocol — a novel consensus mechanism that extends Nakamoto's original design. Unlike traditional blockchains that discard competing blocks, GHOSTDAG allows parallel blocks to coexist and orders them within a Directed Acyclic Graph (blockDAG), enabling high throughput while preserving decentralization and security.
|
|
8
|
+
|
|
9
|
+
**Key Features:**
|
|
10
|
+
- **10 blocks per second** with sub-second finality (Crescendo upgrade, May 2025)
|
|
11
|
+
- **Proof of Work** using kHeavyHash algorithm
|
|
12
|
+
- **Fair launch** - no premine, no ICO, no token allocations
|
|
13
|
+
- **Decentralized** - runs on standard hardware
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install
|
|
19
|
+
npm run build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
Set these environment variables:
|
|
25
|
+
|
|
26
|
+
| Variable | Required | Description |
|
|
27
|
+
|----------|----------|-------------|
|
|
28
|
+
| `KASPA_MNEMONIC` | Yes* | BIP39 mnemonic phrase (24 words) |
|
|
29
|
+
| `KASPA_PRIVATE_KEY` | Yes* | Hex-encoded private key (alternative to mnemonic) |
|
|
30
|
+
| `KASPA_NETWORK` | No | Network: `mainnet`, `testnet-10`, `testnet-11`. Defaults to `mainnet` |
|
|
31
|
+
| `KASPA_ACCOUNT_INDEX` | No | BIP44 account index when using mnemonic. Defaults to `0` |
|
|
32
|
+
|
|
33
|
+
*Either `KASPA_MNEMONIC` or `KASPA_PRIVATE_KEY` must be set.
|
|
34
|
+
|
|
35
|
+
## Usage with Claude Desktop
|
|
36
|
+
|
|
37
|
+
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"kaspa": {
|
|
43
|
+
"command": "npx",
|
|
44
|
+
"args": ["kaspa-mcp"],
|
|
45
|
+
"env": {
|
|
46
|
+
"KASPA_MNEMONIC": "your twenty four word mnemonic phrase here ...",
|
|
47
|
+
"KASPA_NETWORK": "mainnet"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Tools
|
|
55
|
+
|
|
56
|
+
### `get_my_address`
|
|
57
|
+
Get the Kaspa address derived from your configured private key or mnemonic.
|
|
58
|
+
|
|
59
|
+
**Returns:** `{ address: string }`
|
|
60
|
+
|
|
61
|
+
### `get_balance`
|
|
62
|
+
Get balance for a Kaspa address.
|
|
63
|
+
|
|
64
|
+
**Parameters:**
|
|
65
|
+
- `address` (optional): Address to check. Defaults to your wallet address.
|
|
66
|
+
|
|
67
|
+
**Returns:** `{ address: string, balance: string, utxoCount: number }`
|
|
68
|
+
|
|
69
|
+
### `get_fee_estimate`
|
|
70
|
+
Get current fee estimates from the network.
|
|
71
|
+
|
|
72
|
+
**Returns:** `{ priorityFee: string, normalFee: string, lowFee: string }`
|
|
73
|
+
|
|
74
|
+
### `send_kaspa`
|
|
75
|
+
Send KAS tokens to a recipient.
|
|
76
|
+
|
|
77
|
+
**Parameters:**
|
|
78
|
+
- `to`: Recipient Kaspa address (must match wallet network)
|
|
79
|
+
- `amount`: Amount in KAS as string (e.g., "10.5", max 8 decimal places)
|
|
80
|
+
- `priorityFee` (optional): Priority fee in sompi
|
|
81
|
+
|
|
82
|
+
**Returns:** `{ txId: string, fee: string }`
|
|
83
|
+
|
|
84
|
+
**Validations:**
|
|
85
|
+
- Address format and network prefix validation
|
|
86
|
+
- Amount must be a valid positive decimal number
|
|
87
|
+
- Maximum 8 decimal places (1 sompi = 0.00000001 KAS)
|
|
88
|
+
- Insufficient balance check before broadcast
|
|
89
|
+
|
|
90
|
+
### `get_transaction`
|
|
91
|
+
Get transaction details including inputs and outputs.
|
|
92
|
+
|
|
93
|
+
**Parameters:**
|
|
94
|
+
- `txId`: Transaction ID
|
|
95
|
+
|
|
96
|
+
**Returns:**
|
|
97
|
+
```typescript
|
|
98
|
+
{
|
|
99
|
+
txId: string,
|
|
100
|
+
accepted: boolean,
|
|
101
|
+
blockHash?: string,
|
|
102
|
+
blockTime?: number,
|
|
103
|
+
inputs: Array<{ transactionId: string, index: number }>,
|
|
104
|
+
outputs: Array<{ index: number, amount: string, address: string }>
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Example
|
|
109
|
+
|
|
110
|
+

|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
"Send 5 KAS to kaspa:qz..."
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The MCP will:
|
|
117
|
+
1. Validate the recipient address matches your network
|
|
118
|
+
2. Check your balance is sufficient
|
|
119
|
+
3. Build the transaction with KIP-9 compliant fees
|
|
120
|
+
4. Sign with your private key
|
|
121
|
+
5. Broadcast to the network via public nodes
|
|
122
|
+
6. Return the transaction ID
|
|
123
|
+
|
|
124
|
+
## Technical Details
|
|
125
|
+
|
|
126
|
+
- Uses [kaspa-wasm](https://github.com/aspect-build/aspect-cli) for cryptographic operations
|
|
127
|
+
- Connects to public nodes via Resolver for automatic node discovery
|
|
128
|
+
- Implements BIP44 derivation path `m/44'/111111'/account'` for mnemonic wallets
|
|
129
|
+
- Transaction building uses Generator for KIP-9 compliant UTXO management
|
|
130
|
+
|
|
131
|
+
## Security
|
|
132
|
+
|
|
133
|
+
- Private keys and mnemonics are only used locally for signing
|
|
134
|
+
- Keys are never sent to any external service
|
|
135
|
+
- Error messages are sanitized to prevent secret leakage
|
|
136
|
+
- All transactions require explicit user action via MCP tools
|
|
137
|
+
|
|
138
|
+
## Networks
|
|
139
|
+
|
|
140
|
+
| Network | Address Prefix | API Endpoint |
|
|
141
|
+
|---------|---------------|--------------|
|
|
142
|
+
| `mainnet` | `kaspa:` | api.kaspa.org |
|
|
143
|
+
| `testnet-10` | `kaspatest:` | api-tn10.kaspa.org |
|
|
144
|
+
| `testnet-11` | `kaspatest:` | api-tn11.kaspa.org |
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
ISC
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/e2e.test.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// ABOUTME: End-to-end tests for Kaspa MCP server
|
|
2
|
+
// ABOUTME: Tests the MCP server tool handlers with real wallet operations
|
|
3
|
+
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
// Valid test mnemonic (24 words)
|
|
7
|
+
const TEST_MNEMONIC = 'matter client cigar north mixed hard rail kitten flat shrug view group diagram release goose thumb benefit fire confirm swamp skill merry genre visa';
|
|
8
|
+
async function wrapToolHandler(handler) {
|
|
9
|
+
try {
|
|
10
|
+
const result = await handler();
|
|
11
|
+
return {
|
|
12
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: 'text', text: `Error: ${error}` }],
|
|
18
|
+
isError: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
describe('End-to-End Tests', () => {
|
|
23
|
+
const mockFetch = vi.fn();
|
|
24
|
+
const originalFetch = globalThis.fetch;
|
|
25
|
+
const originalEnv = process.env;
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
process.env = {
|
|
28
|
+
...originalEnv,
|
|
29
|
+
KASPA_MNEMONIC: TEST_MNEMONIC,
|
|
30
|
+
KASPA_NETWORK: 'mainnet',
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
afterAll(() => {
|
|
34
|
+
process.env = originalEnv;
|
|
35
|
+
});
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
globalThis.fetch = mockFetch;
|
|
38
|
+
mockFetch.mockReset();
|
|
39
|
+
vi.resetModules();
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
globalThis.fetch = originalFetch;
|
|
43
|
+
});
|
|
44
|
+
describe('wrapToolHandler', () => {
|
|
45
|
+
it('wraps successful result in MCP format', async () => {
|
|
46
|
+
const handler = async () => ({ address: 'kaspa:qptest' });
|
|
47
|
+
const response = await wrapToolHandler(handler);
|
|
48
|
+
expect(response.isError).toBeUndefined();
|
|
49
|
+
expect(response.content).toHaveLength(1);
|
|
50
|
+
expect(response.content[0].type).toBe('text');
|
|
51
|
+
const parsed = JSON.parse(response.content[0].text);
|
|
52
|
+
expect(parsed.address).toBe('kaspa:qptest');
|
|
53
|
+
});
|
|
54
|
+
it('wraps error in MCP format with isError flag', async () => {
|
|
55
|
+
const handler = async () => {
|
|
56
|
+
throw new Error('Test error');
|
|
57
|
+
};
|
|
58
|
+
const response = await wrapToolHandler(handler);
|
|
59
|
+
expect(response.isError).toBe(true);
|
|
60
|
+
expect(response.content).toHaveLength(1);
|
|
61
|
+
expect(response.content[0].text).toContain('Error:');
|
|
62
|
+
expect(response.content[0].text).toContain('Test error');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('MCP Server Tool Registration', () => {
|
|
66
|
+
it('can create MCP server with tool capabilities', () => {
|
|
67
|
+
const server = new McpServer({ name: 'test-kaspa-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
68
|
+
expect(server).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
it('can register get_my_address tool', async () => {
|
|
71
|
+
const server = new McpServer({ name: 'test-kaspa-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
72
|
+
const { getMyAddress } = await import('./tools/get-my-address.js');
|
|
73
|
+
server.tool('get_my_address', 'Get the Kaspa address derived from the configured private key', async () => wrapToolHandler(() => getMyAddress()));
|
|
74
|
+
expect(server).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
it('can register get_balance tool with schema', async () => {
|
|
77
|
+
const server = new McpServer({ name: 'test-kaspa-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
78
|
+
const { getBalance } = await import('./tools/get-balance.js');
|
|
79
|
+
server.tool('get_balance', 'Get balance for a Kaspa address', {
|
|
80
|
+
address: z.string().optional().describe('Kaspa address to check'),
|
|
81
|
+
}, async (params) => wrapToolHandler(() => getBalance({ address: params.address })));
|
|
82
|
+
expect(server).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
it('can register send_kaspa tool with required params', async () => {
|
|
85
|
+
const server = new McpServer({ name: 'test-kaspa-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
86
|
+
const { sendKaspa } = await import('./tools/send-kaspa.js');
|
|
87
|
+
server.tool('send_kaspa', 'Send KAS tokens', {
|
|
88
|
+
to: z.string().describe('Recipient address'),
|
|
89
|
+
amount: z.string().describe('Amount in KAS'),
|
|
90
|
+
priorityFee: z.number().optional().describe('Priority fee in sompi'),
|
|
91
|
+
}, async (params) => wrapToolHandler(() => sendKaspa({
|
|
92
|
+
to: params.to,
|
|
93
|
+
amount: params.amount,
|
|
94
|
+
priorityFee: params.priorityFee,
|
|
95
|
+
})));
|
|
96
|
+
expect(server).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('Full Tool Flow E2E', () => {
|
|
100
|
+
it('get_my_address returns valid mainnet address', async () => {
|
|
101
|
+
const { getMyAddress } = await import('./tools/get-my-address.js');
|
|
102
|
+
const response = await wrapToolHandler(() => getMyAddress());
|
|
103
|
+
expect(response.isError).toBeUndefined();
|
|
104
|
+
const result = JSON.parse(response.content[0].text);
|
|
105
|
+
expect(result.address).toMatch(/^kaspa:/);
|
|
106
|
+
});
|
|
107
|
+
it('get_balance returns formatted balance', async () => {
|
|
108
|
+
mockFetch
|
|
109
|
+
.mockResolvedValueOnce({
|
|
110
|
+
ok: true,
|
|
111
|
+
json: () => Promise.resolve({ balance: '10000000000' }),
|
|
112
|
+
})
|
|
113
|
+
.mockResolvedValueOnce({
|
|
114
|
+
ok: true,
|
|
115
|
+
json: () => Promise.resolve([{ utxo: 1 }, { utxo: 2 }, { utxo: 3 }]),
|
|
116
|
+
});
|
|
117
|
+
const { getBalance } = await import('./tools/get-balance.js');
|
|
118
|
+
const response = await wrapToolHandler(() => getBalance({}));
|
|
119
|
+
expect(response.isError).toBeUndefined();
|
|
120
|
+
const result = JSON.parse(response.content[0].text);
|
|
121
|
+
expect(result.balance).toBe('100');
|
|
122
|
+
expect(result.utxoCount).toBe(3);
|
|
123
|
+
});
|
|
124
|
+
it('get_fee_estimate returns all fee tiers', async () => {
|
|
125
|
+
mockFetch.mockResolvedValueOnce({
|
|
126
|
+
ok: true,
|
|
127
|
+
json: () => Promise.resolve({
|
|
128
|
+
priorityBucket: { feerate: 2.0 },
|
|
129
|
+
normalBuckets: [{ feerate: 1.5 }],
|
|
130
|
+
lowBuckets: [{ feerate: 1.0 }],
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
const { getFeeEstimate } = await import('./tools/get-fee-estimate.js');
|
|
134
|
+
const response = await wrapToolHandler(() => getFeeEstimate());
|
|
135
|
+
expect(response.isError).toBeUndefined();
|
|
136
|
+
const result = JSON.parse(response.content[0].text);
|
|
137
|
+
expect(result.priorityFee).toBe('2');
|
|
138
|
+
expect(result.normalFee).toBe('1.5');
|
|
139
|
+
expect(result.lowFee).toBe('1');
|
|
140
|
+
});
|
|
141
|
+
it('get_transaction returns transaction status', async () => {
|
|
142
|
+
mockFetch.mockResolvedValueOnce({
|
|
143
|
+
ok: true,
|
|
144
|
+
json: () => Promise.resolve({
|
|
145
|
+
transaction_id: 'txid123',
|
|
146
|
+
is_accepted: true,
|
|
147
|
+
block_hash: ['hash1'],
|
|
148
|
+
block_time: 1234567890,
|
|
149
|
+
inputs: [{ previous_outpoint_hash: 'prev_tx', previous_outpoint_index: 0 }],
|
|
150
|
+
outputs: [{ amount: '10000000000', script_public_key_address: 'kaspa:qptest' }],
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
const { getTransaction } = await import('./tools/get-transaction.js');
|
|
154
|
+
const response = await wrapToolHandler(() => getTransaction({ txId: 'txid123' }));
|
|
155
|
+
expect(response.isError).toBeUndefined();
|
|
156
|
+
const result = JSON.parse(response.content[0].text);
|
|
157
|
+
expect(result.txId).toBe('txid123');
|
|
158
|
+
expect(result.accepted).toBe(true);
|
|
159
|
+
expect(result.outputs).toHaveLength(1);
|
|
160
|
+
expect(result.outputs[0].address).toBe('kaspa:qptest');
|
|
161
|
+
});
|
|
162
|
+
it('send_kaspa validation error returns isError', async () => {
|
|
163
|
+
const { sendKaspa } = await import('./tools/send-kaspa.js');
|
|
164
|
+
const response = await wrapToolHandler(() => sendKaspa({ to: 'invalid', amount: '1' }));
|
|
165
|
+
expect(response.isError).toBe(true);
|
|
166
|
+
expect(response.content[0].text).toContain('Invalid Kaspa address');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe('Error Handling E2E', () => {
|
|
170
|
+
it('API timeout is handled gracefully', async () => {
|
|
171
|
+
mockFetch.mockRejectedValueOnce(new Error('Network timeout'));
|
|
172
|
+
const { getFeeEstimate } = await import('./tools/get-fee-estimate.js');
|
|
173
|
+
const response = await wrapToolHandler(() => getFeeEstimate());
|
|
174
|
+
expect(response.isError).toBe(true);
|
|
175
|
+
expect(response.content[0].text).toContain('Network timeout');
|
|
176
|
+
});
|
|
177
|
+
it('missing txId parameter returns error', async () => {
|
|
178
|
+
const { getTransaction } = await import('./tools/get-transaction.js');
|
|
179
|
+
const response = await wrapToolHandler(() => getTransaction({ txId: '' }));
|
|
180
|
+
expect(response.isError).toBe(true);
|
|
181
|
+
expect(response.content[0].text).toContain('Transaction ID (txId) is required');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ABOUTME: MCP server entry point for Kaspa transactions
|
|
3
|
+
// ABOUTME: Registers all tools and starts the stdio transport
|
|
4
|
+
import './kaspa/setup.js';
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { getMyAddress } from './tools/get-my-address.js';
|
|
9
|
+
import { getBalance } from './tools/get-balance.js';
|
|
10
|
+
import { getFeeEstimate } from './tools/get-fee-estimate.js';
|
|
11
|
+
import { sendKaspa } from './tools/send-kaspa.js';
|
|
12
|
+
import { getTransaction } from './tools/get-transaction.js';
|
|
13
|
+
async function wrapToolHandler(handler) {
|
|
14
|
+
try {
|
|
15
|
+
const result = await handler();
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
|
|
24
|
+
isError: true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const server = new McpServer({
|
|
29
|
+
name: 'kaspa-mcp',
|
|
30
|
+
version: '0.1.0',
|
|
31
|
+
}, {
|
|
32
|
+
capabilities: {
|
|
33
|
+
tools: {},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
server.tool('get_my_address', 'Get the Kaspa address derived from the configured private key', async () => wrapToolHandler(() => getMyAddress()));
|
|
37
|
+
server.tool('get_balance', 'Get balance for a Kaspa address (defaults to your wallet address)', {
|
|
38
|
+
address: z.string().optional().describe('Kaspa address to check (optional, defaults to your address)'),
|
|
39
|
+
}, async (params) => wrapToolHandler(() => getBalance({ address: params.address })));
|
|
40
|
+
server.tool('get_fee_estimate', 'Get current fee estimates from the Kaspa network', async () => wrapToolHandler(() => getFeeEstimate()));
|
|
41
|
+
server.tool('send_kaspa', 'Send KAS tokens to a recipient address', {
|
|
42
|
+
to: z.string().describe('Recipient Kaspa address'),
|
|
43
|
+
amount: z.string().describe('Amount to send in KAS'),
|
|
44
|
+
priorityFee: z.number().optional().describe('Priority fee in sompi (optional)'),
|
|
45
|
+
}, async (params) => wrapToolHandler(() => sendKaspa({
|
|
46
|
+
to: params.to,
|
|
47
|
+
amount: params.amount,
|
|
48
|
+
priorityFee: params.priorityFee,
|
|
49
|
+
})));
|
|
50
|
+
server.tool('get_transaction', 'Get transaction status and details', {
|
|
51
|
+
txId: z.string().describe('Transaction ID'),
|
|
52
|
+
}, async (params) => wrapToolHandler(() => getTransaction({ txId: params.txId })));
|
|
53
|
+
async function main() {
|
|
54
|
+
const transport = new StdioServerTransport();
|
|
55
|
+
await server.connect(transport);
|
|
56
|
+
console.error('Kaspa MCP server started');
|
|
57
|
+
}
|
|
58
|
+
main().catch((error) => {
|
|
59
|
+
// Log only the message to prevent secret leakage in stack traces
|
|
60
|
+
const safeMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
61
|
+
console.error('Fatal error:', safeMessage);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// ABOUTME: Integration tests for Kaspa MCP tools
|
|
2
|
+
// ABOUTME: Tests tool flows with real kaspa-wasm but mocked network calls
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
|
4
|
+
// Valid test mnemonic (24 words)
|
|
5
|
+
const TEST_MNEMONIC = 'matter client cigar north mixed hard rail kitten flat shrug view group diagram release goose thumb benefit fire confirm swamp skill merry genre visa';
|
|
6
|
+
describe('Integration Tests', () => {
|
|
7
|
+
const mockFetch = vi.fn();
|
|
8
|
+
const originalFetch = globalThis.fetch;
|
|
9
|
+
const originalEnv = process.env;
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
process.env = {
|
|
12
|
+
...originalEnv,
|
|
13
|
+
KASPA_MNEMONIC: TEST_MNEMONIC,
|
|
14
|
+
KASPA_NETWORK: 'mainnet',
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
afterAll(() => {
|
|
18
|
+
process.env = originalEnv;
|
|
19
|
+
});
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
globalThis.fetch = mockFetch;
|
|
22
|
+
mockFetch.mockReset();
|
|
23
|
+
vi.resetModules();
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
globalThis.fetch = originalFetch;
|
|
27
|
+
});
|
|
28
|
+
describe('get-my-address tool', () => {
|
|
29
|
+
it('returns wallet address derived from mnemonic', async () => {
|
|
30
|
+
const { getMyAddress } = await import('./tools/get-my-address.js');
|
|
31
|
+
const result = await getMyAddress();
|
|
32
|
+
expect(result.address).toMatch(/^kaspa:/);
|
|
33
|
+
expect(result.address).toHaveLength(67);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe('get-balance tool', () => {
|
|
37
|
+
it('returns balance for wallet address', async () => {
|
|
38
|
+
mockFetch
|
|
39
|
+
.mockResolvedValueOnce({
|
|
40
|
+
ok: true,
|
|
41
|
+
json: () => Promise.resolve({ address: 'kaspa:test', balance: '5000000000' }),
|
|
42
|
+
})
|
|
43
|
+
.mockResolvedValueOnce({
|
|
44
|
+
ok: true,
|
|
45
|
+
json: () => Promise.resolve([
|
|
46
|
+
{ address: 'kaspa:test', outpoint: { transactionId: 'tx1', index: 0 }, utxoEntry: { amount: '5000000000' } },
|
|
47
|
+
]),
|
|
48
|
+
});
|
|
49
|
+
const { getBalance } = await import('./tools/get-balance.js');
|
|
50
|
+
const result = await getBalance({});
|
|
51
|
+
expect(result.address).toMatch(/^kaspa:/);
|
|
52
|
+
expect(result.balance).toBe('50');
|
|
53
|
+
expect(result.utxoCount).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
it('returns balance for specified address', async () => {
|
|
56
|
+
const testAddress = 'kaspa:qptest123456789abcdef';
|
|
57
|
+
mockFetch
|
|
58
|
+
.mockResolvedValueOnce({
|
|
59
|
+
ok: true,
|
|
60
|
+
json: () => Promise.resolve({ address: testAddress, balance: '1000000000' }),
|
|
61
|
+
})
|
|
62
|
+
.mockResolvedValueOnce({
|
|
63
|
+
ok: true,
|
|
64
|
+
json: () => Promise.resolve([]),
|
|
65
|
+
});
|
|
66
|
+
const { getBalance } = await import('./tools/get-balance.js');
|
|
67
|
+
const result = await getBalance({ address: testAddress });
|
|
68
|
+
expect(result.address).toBe(testAddress);
|
|
69
|
+
expect(result.balance).toBe('10');
|
|
70
|
+
expect(result.utxoCount).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('get-fee-estimate tool', () => {
|
|
74
|
+
it('returns formatted fee estimates', async () => {
|
|
75
|
+
mockFetch.mockResolvedValueOnce({
|
|
76
|
+
ok: true,
|
|
77
|
+
json: () => Promise.resolve({
|
|
78
|
+
priorityBucket: { feerate: 1.5, estimatedSeconds: 10 },
|
|
79
|
+
normalBuckets: [{ feerate: 1.0, estimatedSeconds: 30 }],
|
|
80
|
+
lowBuckets: [{ feerate: 0.5, estimatedSeconds: 60 }],
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
const { getFeeEstimate } = await import('./tools/get-fee-estimate.js');
|
|
84
|
+
const result = await getFeeEstimate();
|
|
85
|
+
expect(result.priorityFee).toBe('1.5');
|
|
86
|
+
expect(result.normalFee).toBe('1');
|
|
87
|
+
expect(result.lowFee).toBe('0.5');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('get-transaction tool', () => {
|
|
91
|
+
it('returns transaction details', async () => {
|
|
92
|
+
mockFetch.mockResolvedValueOnce({
|
|
93
|
+
ok: true,
|
|
94
|
+
json: () => Promise.resolve({
|
|
95
|
+
transaction_id: 'abc123def456',
|
|
96
|
+
is_accepted: true,
|
|
97
|
+
block_hash: ['blockhash1'],
|
|
98
|
+
block_time: 1234567890,
|
|
99
|
+
inputs: [],
|
|
100
|
+
outputs: [],
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
const { getTransaction } = await import('./tools/get-transaction.js');
|
|
104
|
+
const result = await getTransaction({ txId: 'abc123def456' });
|
|
105
|
+
expect(result.txId).toBe('abc123def456');
|
|
106
|
+
expect(result.accepted).toBe(true);
|
|
107
|
+
expect(result.blockHash).toBe('blockhash1');
|
|
108
|
+
});
|
|
109
|
+
it('throws for missing transaction', async () => {
|
|
110
|
+
mockFetch.mockResolvedValueOnce({
|
|
111
|
+
ok: false,
|
|
112
|
+
status: 404,
|
|
113
|
+
text: () => Promise.resolve('Not found'),
|
|
114
|
+
});
|
|
115
|
+
const { getTransaction } = await import('./tools/get-transaction.js');
|
|
116
|
+
await expect(getTransaction({ txId: 'notfound' })).rejects.toThrow('Transaction not found: notfound');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('send-kaspa tool validation', () => {
|
|
120
|
+
it('validates recipient address format', async () => {
|
|
121
|
+
const { sendKaspa } = await import('./tools/send-kaspa.js');
|
|
122
|
+
await expect(sendKaspa({ to: 'invalid', amount: '1' })).rejects.toThrow('Invalid Kaspa address');
|
|
123
|
+
});
|
|
124
|
+
it('validates amount format', async () => {
|
|
125
|
+
const { sendKaspa } = await import('./tools/send-kaspa.js');
|
|
126
|
+
// Use a valid mainnet address format for this test
|
|
127
|
+
const validMainnetAddress = 'kaspa:qpamkvhgh0kzx50gwvvp5xs8ktmqutcy3dfs9dc3w7lm9rq0zs76vf959mmrp';
|
|
128
|
+
await expect(sendKaspa({ to: validMainnetAddress, amount: 'abc' })).rejects.toThrow('Amount must be a valid decimal number');
|
|
129
|
+
});
|
|
130
|
+
it('validates network mismatch', async () => {
|
|
131
|
+
// Create a testnet wallet to get a valid testnet address
|
|
132
|
+
const { KaspaWallet } = await import('./kaspa/wallet.js');
|
|
133
|
+
const testnetWallet = KaspaWallet.fromMnemonic(TEST_MNEMONIC, 'testnet-10');
|
|
134
|
+
const validTestnetAddress = testnetWallet.getAddress();
|
|
135
|
+
const { sendKaspa } = await import('./tools/send-kaspa.js');
|
|
136
|
+
// Wallet is on mainnet (from beforeAll), so sending to testnet address should fail
|
|
137
|
+
await expect(sendKaspa({ to: validTestnetAddress, amount: '1' })).rejects.toThrow('Address network mismatch');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe('wallet consistency', () => {
|
|
141
|
+
it('generates same address from same mnemonic', async () => {
|
|
142
|
+
const { getMyAddress } = await import('./tools/get-my-address.js');
|
|
143
|
+
const result1 = await getMyAddress();
|
|
144
|
+
const result2 = await getMyAddress();
|
|
145
|
+
expect(result1.address).toBe(result2.address);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('API error handling', () => {
|
|
149
|
+
it('handles API errors gracefully', async () => {
|
|
150
|
+
mockFetch.mockResolvedValueOnce({
|
|
151
|
+
ok: false,
|
|
152
|
+
status: 500,
|
|
153
|
+
text: () => Promise.resolve('Internal server error'),
|
|
154
|
+
});
|
|
155
|
+
const { getFeeEstimate } = await import('./tools/get-fee-estimate.js');
|
|
156
|
+
await expect(getFeeEstimate()).rejects.toThrow('API error 500');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { UtxoResponse, BalanceResponse, FeeEstimateResponse, TransactionResponse } from '../types.js';
|
|
2
|
+
export declare class KaspaApi {
|
|
3
|
+
private baseUrl;
|
|
4
|
+
constructor(network?: string);
|
|
5
|
+
private fetch;
|
|
6
|
+
getBalance(address: string): Promise<BalanceResponse>;
|
|
7
|
+
getUtxos(address: string): Promise<UtxoResponse[]>;
|
|
8
|
+
getFeeEstimate(): Promise<FeeEstimateResponse>;
|
|
9
|
+
getTransaction(txId: string): Promise<TransactionResponse>;
|
|
10
|
+
}
|
|
11
|
+
export declare function getApi(network: string): KaspaApi;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ABOUTME: REST API client for Kaspa REST APIs
|
|
2
|
+
// ABOUTME: Handles UTXO queries, balance checks, fee estimates - supports mainnet and testnet
|
|
3
|
+
const API_ENDPOINTS = {
|
|
4
|
+
mainnet: 'https://api.kaspa.org',
|
|
5
|
+
'testnet-10': 'https://api-tn10.kaspa.org',
|
|
6
|
+
'testnet-11': 'https://api-tn11.kaspa.org',
|
|
7
|
+
};
|
|
8
|
+
export class KaspaApi {
|
|
9
|
+
baseUrl;
|
|
10
|
+
constructor(network = 'mainnet') {
|
|
11
|
+
this.baseUrl = API_ENDPOINTS[network] || API_ENDPOINTS.mainnet;
|
|
12
|
+
}
|
|
13
|
+
async fetch(path, options) {
|
|
14
|
+
const url = `${this.baseUrl}${path}`;
|
|
15
|
+
const response = await fetch(url, {
|
|
16
|
+
...options,
|
|
17
|
+
headers: {
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
...options?.headers,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
const text = await response.text();
|
|
24
|
+
throw new Error(`API error ${response.status}: ${text}`);
|
|
25
|
+
}
|
|
26
|
+
return response.json();
|
|
27
|
+
}
|
|
28
|
+
async getBalance(address) {
|
|
29
|
+
return this.fetch(`/addresses/${address}/balance`);
|
|
30
|
+
}
|
|
31
|
+
async getUtxos(address) {
|
|
32
|
+
return this.fetch(`/addresses/${address}/utxos`);
|
|
33
|
+
}
|
|
34
|
+
async getFeeEstimate() {
|
|
35
|
+
return this.fetch('/info/fee-estimate');
|
|
36
|
+
}
|
|
37
|
+
async getTransaction(txId) {
|
|
38
|
+
return this.fetch(`/transactions/${txId}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let apiInstance = null;
|
|
42
|
+
let apiNetwork = null;
|
|
43
|
+
export function getApi(network) {
|
|
44
|
+
if (!apiInstance || apiNetwork !== network) {
|
|
45
|
+
apiInstance = new KaspaApi(network);
|
|
46
|
+
apiNetwork = network;
|
|
47
|
+
}
|
|
48
|
+
return apiInstance;
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|