@goplausible/algorand-mcp 4.3.0 → 4.3.1
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/README.md +23 -2
- package/dist/tools/utilityManager.js +1 -1
- package/dist/tools/walletManager.d.ts +9 -0
- package/dist/tools/walletManager.js +126 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -319,7 +319,28 @@ Older installs of this MCP stored mnemonics in the OS keychain (`@napi-rs/keyrin
|
|
|
319
319
|
|
|
320
320
|
After this completes, the DB is the sole source of truth. The keychain is consulted only as a fallback if the DB still has a `NULL` mnemonic for an address (e.g., the keychain was unavailable during startup and became available later). All new accounts created after the upgrade are written directly to the DB and never touch the keychain.
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
**Orphan handling (archive, not delete).** If an `accounts` row exists but its mnemonic isn't in the keychain *and* isn't already in the DB (e.g., the user copied `wallet.db` to a new machine without also moving the keychain entries, restored from a partial backup, or installed in Docker where the keychain never existed), that row is unusable for signing. Rather than delete it, the server marks the row as **archived** (`UPDATE accounts SET archived = 1 WHERE mnemonic IS NULL OR mnemonic = ''`). Archived rows:
|
|
323
|
+
|
|
324
|
+
- are **hidden from the default** `wallet_list_accounts` response
|
|
325
|
+
- **never become the active account** (the active-account index is clamped to the end of the remaining active list, or reset to `0` if no active accounts remain)
|
|
326
|
+
- keep their **original nickname** (a partial unique index `idx_active_nickname` enforces nickname uniqueness only among active rows, so a new `wallet_add_account` can reuse the same nickname for a fresh keypair)
|
|
327
|
+
- are **surfaced via `wallet_list_accounts { archived: true }`** for forensics or future recovery
|
|
328
|
+
|
|
329
|
+
Archiving is silent at the MCP tool layer. The only diagnostic is a one-line stderr log per failed keychain read (`[algorand-mcp] keychain read failed for <addr>…: <msg>`), so if a user investigates a false archive they can see whether the keychain threw "no entry" vs "access denied" vs "no DBus" etc.
|
|
330
|
+
|
|
331
|
+
No user action is required for any of this. No prompts, no env vars, no migration tools.
|
|
332
|
+
|
|
333
|
+
### Schema versions
|
|
334
|
+
|
|
335
|
+
The DB schema evolves additively via an idempotent migration that runs at startup:
|
|
336
|
+
|
|
337
|
+
| Version | Change |
|
|
338
|
+
|---|---|
|
|
339
|
+
| v1 | initial — `accounts` columns: `id`, `address`, `public_key`, `nickname` (UNIQUE), `created_at` |
|
|
340
|
+
| v2 | added `mnemonic TEXT` column |
|
|
341
|
+
| v3 | added `archived INTEGER NOT NULL DEFAULT 0` column; dropped the column-level UNIQUE on `nickname` and replaced it with `CREATE UNIQUE INDEX idx_active_nickname ON accounts(nickname) WHERE archived = 0` so archived rows can keep their original nicknames without blocking reuse |
|
|
342
|
+
|
|
343
|
+
The v2→v3 step recreates the `accounts` table (SQLite cannot drop a column-level UNIQUE constraint via ALTER) and copies data forward with `archived = 0`. Existing wallets keep working unchanged.
|
|
323
344
|
|
|
324
345
|
## x402 HTTP Payments
|
|
325
346
|
|
|
@@ -451,7 +472,7 @@ See [Secure Wallet](#secure-wallet) for full architecture details.
|
|
|
451
472
|
|---|---|
|
|
452
473
|
| `wallet_add_account` | Create a new Algorand account with nickname (returns address + public key only) |
|
|
453
474
|
| `wallet_remove_account` | Remove an account from the wallet by nickname or index |
|
|
454
|
-
| `wallet_list_accounts` | List
|
|
475
|
+
| `wallet_list_accounts` | List active accounts with nicknames and addresses. Pass `{ archived: true }` to list archived accounts instead (rows whose mnemonic couldn't be recovered from the OS keychain at startup — kept in the DB for forensics, not signable). |
|
|
455
476
|
| `wallet_switch_account` | Switch the active account by nickname or index |
|
|
456
477
|
| `wallet_get_info` | Get info for the **active account this MCP server owns** (DB-backed): address, public key, balance, opted-in counts. For arbitrary on-chain accounts use `api_algod_get_account_info`. |
|
|
457
478
|
| `wallet_get_assets` | Get all ASA holdings for the **active account this MCP server owns**. For arbitrary on-chain accounts use `api_algod_get_account_info` or `api_algod_get_account_asset_info`. |
|
|
@@ -86,7 +86,7 @@ export class UtilityManager {
|
|
|
86
86
|
type: 'text',
|
|
87
87
|
text: JSON.stringify({
|
|
88
88
|
name: 'Algorand MCP Server',
|
|
89
|
-
version: '4.3.
|
|
89
|
+
version: '4.3.1',
|
|
90
90
|
builder: 'GoPlausible',
|
|
91
91
|
description: 'A Model Context Protocol (MCP) server providing comprehensive access to the Algorand blockchain. Supports account management, transaction building and signing, smart contract interaction, asset operations, ARC-26 URI generation, and deep integration with Algorand ecosystem services including NFDomains, Tinyman, Vestige, and Ultrade.',
|
|
92
92
|
blockchain: 'Algorand — a carbon-negative, pure proof-of-stake Layer 1 blockchain delivering instant finality, low fees, and advanced smart contract capabilities via AVM (Algorand Virtual Machine).',
|
|
@@ -4,6 +4,7 @@ export interface AccountRow {
|
|
|
4
4
|
public_key: string;
|
|
5
5
|
nickname: string;
|
|
6
6
|
mnemonic: string | null;
|
|
7
|
+
archived: number;
|
|
7
8
|
created_at: string;
|
|
8
9
|
}
|
|
9
10
|
export declare class WalletManager {
|
|
@@ -12,6 +13,14 @@ export declare class WalletManager {
|
|
|
12
13
|
description: string;
|
|
13
14
|
inputSchema: any;
|
|
14
15
|
}[];
|
|
16
|
+
/**
|
|
17
|
+
* Returns accounts from wallet.db.
|
|
18
|
+
* - includeArchived = false (default): only active rows (archived = 0).
|
|
19
|
+
* Used by every signing/lookup path and by wallet_list_accounts in its
|
|
20
|
+
* default mode.
|
|
21
|
+
* - includeArchived = true: only archived rows (archived = 1). Used by
|
|
22
|
+
* wallet_list_accounts { archived: true } for forensics.
|
|
23
|
+
*/
|
|
15
24
|
private static getAllAccounts;
|
|
16
25
|
private static getActiveIndex;
|
|
17
26
|
private static setActiveIndex;
|
|
@@ -28,14 +28,16 @@ async function getDb() {
|
|
|
28
28
|
else {
|
|
29
29
|
db = new SQL.Database();
|
|
30
30
|
}
|
|
31
|
-
// Create tables if they don't exist
|
|
31
|
+
// Create tables if they don't exist. For fresh installs the schema is
|
|
32
|
+
// already at the latest shape; for older DBs ensureSchema() below upgrades.
|
|
32
33
|
db.run(`
|
|
33
34
|
CREATE TABLE IF NOT EXISTS accounts (
|
|
34
35
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
36
|
address TEXT UNIQUE NOT NULL,
|
|
36
37
|
public_key TEXT NOT NULL,
|
|
37
|
-
nickname TEXT
|
|
38
|
+
nickname TEXT NOT NULL,
|
|
38
39
|
mnemonic TEXT,
|
|
40
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
39
41
|
created_at TEXT NOT NULL
|
|
40
42
|
)
|
|
41
43
|
`);
|
|
@@ -45,8 +47,12 @@ async function getDb() {
|
|
|
45
47
|
value TEXT NOT NULL
|
|
46
48
|
)
|
|
47
49
|
`);
|
|
48
|
-
// Idempotent schema migration:
|
|
49
|
-
|
|
50
|
+
// Idempotent schema migration: add mnemonic + archived columns on
|
|
51
|
+
// pre-existing DBs; replace the legacy column-level UNIQUE on nickname with
|
|
52
|
+
// a partial unique index that only enforces uniqueness among non-archived
|
|
53
|
+
// accounts (so an archived account can keep its original nickname while a
|
|
54
|
+
// new active account with the same nickname can be created).
|
|
55
|
+
ensureSchema(db);
|
|
50
56
|
// Initialize active index if not set
|
|
51
57
|
const row = db.exec("SELECT value FROM wallet_state WHERE key = 'active_account_index'");
|
|
52
58
|
if (row.length === 0) {
|
|
@@ -56,36 +62,113 @@ async function getDb() {
|
|
|
56
62
|
// still lives in the OS keychain. Runs once per startup; idempotent (rows
|
|
57
63
|
// already populated are skipped).
|
|
58
64
|
migrateKeychainToDb(db);
|
|
65
|
+
// Any row that's still NULL after the migration attempt is an orphan
|
|
66
|
+
// (e.g. wallet.db was restored without its keychain counterpart, or this
|
|
67
|
+
// is a Docker install where the keychain never existed). Archive them so
|
|
68
|
+
// the agent doesn't see unusable accounts in the default wallet_list, but
|
|
69
|
+
// the rows stay in the DB and can be surfaced via wallet_list_accounts
|
|
70
|
+
// { archived: true } for forensics or future recovery.
|
|
71
|
+
archiveOrphanedAccounts(db);
|
|
59
72
|
persistDb();
|
|
60
73
|
return db;
|
|
61
74
|
}
|
|
62
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Brings older DBs forward to the latest schema:
|
|
77
|
+
* v1 → no mnemonic column, nickname is a column-level UNIQUE
|
|
78
|
+
* v2 → mnemonic column added, nickname still column-level UNIQUE
|
|
79
|
+
* v3 → archived column added, nickname UNIQUE replaced with a partial unique
|
|
80
|
+
* index (idx_active_nickname) that only enforces uniqueness among rows
|
|
81
|
+
* WHERE archived = 0
|
|
82
|
+
*
|
|
83
|
+
* SQLite cannot drop a column-level UNIQUE constraint via ALTER, so when the
|
|
84
|
+
* upgrade to v3 is needed we recreate the table. Idempotent: subsequent
|
|
85
|
+
* startups detect the archived column already exists and skip.
|
|
86
|
+
*/
|
|
87
|
+
function ensureSchema(database) {
|
|
63
88
|
const info = database.exec('PRAGMA table_info(accounts)');
|
|
64
89
|
if (!info.length)
|
|
65
90
|
return;
|
|
66
91
|
const cols = info[0].values.map(r => r[1]);
|
|
92
|
+
// v1 → v2: add mnemonic column
|
|
67
93
|
if (!cols.includes('mnemonic')) {
|
|
68
94
|
database.run('ALTER TABLE accounts ADD COLUMN mnemonic TEXT');
|
|
95
|
+
cols.push('mnemonic');
|
|
69
96
|
}
|
|
97
|
+
// v2 → v3: add archived column + drop nickname UNIQUE via table recreation
|
|
98
|
+
if (!cols.includes('archived')) {
|
|
99
|
+
database.run(`
|
|
100
|
+
CREATE TABLE accounts_new (
|
|
101
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
102
|
+
address TEXT UNIQUE NOT NULL,
|
|
103
|
+
public_key TEXT NOT NULL,
|
|
104
|
+
nickname TEXT NOT NULL,
|
|
105
|
+
mnemonic TEXT,
|
|
106
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
107
|
+
created_at TEXT NOT NULL
|
|
108
|
+
)
|
|
109
|
+
`);
|
|
110
|
+
database.run(`
|
|
111
|
+
INSERT INTO accounts_new (id, address, public_key, nickname, mnemonic, archived, created_at)
|
|
112
|
+
SELECT id, address, public_key, nickname, mnemonic, 0, created_at FROM accounts
|
|
113
|
+
`);
|
|
114
|
+
database.run('DROP TABLE accounts');
|
|
115
|
+
database.run('ALTER TABLE accounts_new RENAME TO accounts');
|
|
116
|
+
}
|
|
117
|
+
// Partial unique index — applies to fresh and migrated DBs. Idempotent.
|
|
118
|
+
database.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_active_nickname ON accounts(nickname) WHERE archived = 0');
|
|
70
119
|
}
|
|
71
120
|
function migrateKeychainToDb(database) {
|
|
72
|
-
const result = database.exec("SELECT address FROM accounts WHERE mnemonic IS NULL OR mnemonic = ''");
|
|
121
|
+
const result = database.exec("SELECT address FROM accounts WHERE (mnemonic IS NULL OR mnemonic = '') AND archived = 0");
|
|
73
122
|
if (!result.length || !result[0].values.length)
|
|
74
123
|
return;
|
|
75
124
|
for (const row of result[0].values) {
|
|
76
125
|
const address = row[0];
|
|
126
|
+
let mnemonic = null;
|
|
77
127
|
try {
|
|
78
|
-
|
|
79
|
-
if (mnemonic) {
|
|
80
|
-
database.run('UPDATE accounts SET mnemonic = ? WHERE address = ?', [mnemonic, address]);
|
|
81
|
-
}
|
|
128
|
+
mnemonic = new Entry(KEYCHAIN_SERVICE, address).getPassword();
|
|
82
129
|
}
|
|
83
|
-
catch {
|
|
84
|
-
//
|
|
85
|
-
//
|
|
130
|
+
catch (err) {
|
|
131
|
+
// Keychain unavailable, entry not found, or permission denied. Log to
|
|
132
|
+
// stderr so a user investigating a false archive can see what happened.
|
|
133
|
+
// The MCP tool surface remains silent.
|
|
134
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
135
|
+
console.error(`[algorand-mcp] keychain read failed for ${address.slice(0, 10)}…: ${msg}`);
|
|
136
|
+
}
|
|
137
|
+
if (mnemonic && mnemonic.length > 0) {
|
|
138
|
+
database.run('UPDATE accounts SET mnemonic = ? WHERE address = ?', [mnemonic, address]);
|
|
86
139
|
}
|
|
87
140
|
}
|
|
88
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Marks any `accounts` rows that still have no mnemonic after the keychain
|
|
144
|
+
* migration step as archived (archived = 1). Archived rows:
|
|
145
|
+
* - are hidden from the default wallet_list_accounts response
|
|
146
|
+
* - never become the active account
|
|
147
|
+
* - keep their original nickname (the partial unique index on
|
|
148
|
+
* idx_active_nickname allows a new active account to reuse the nickname)
|
|
149
|
+
* - are surfaced via wallet_list_accounts { archived: true } for forensics
|
|
150
|
+
*
|
|
151
|
+
* Silent at the MCP tool layer. The active-account index is clamped to the
|
|
152
|
+
* end of the remaining active list (or 0 if no active accounts remain).
|
|
153
|
+
*/
|
|
154
|
+
function archiveOrphanedAccounts(database) {
|
|
155
|
+
const orphans = database.exec("SELECT id FROM accounts WHERE (mnemonic IS NULL OR mnemonic = '') AND archived = 0");
|
|
156
|
+
if (!orphans.length || !orphans[0].values.length)
|
|
157
|
+
return;
|
|
158
|
+
database.run("UPDATE accounts SET archived = 1 WHERE (mnemonic IS NULL OR mnemonic = '') AND archived = 0");
|
|
159
|
+
// Clamp active_account_index against the (now reduced) active list.
|
|
160
|
+
const countResult = database.exec('SELECT COUNT(*) FROM accounts WHERE archived = 0');
|
|
161
|
+
const remaining = countResult.length && countResult[0].values.length
|
|
162
|
+
? Number(countResult[0].values[0][0])
|
|
163
|
+
: 0;
|
|
164
|
+
const activeResult = database.exec("SELECT value FROM wallet_state WHERE key = 'active_account_index'");
|
|
165
|
+
const activeIdx = activeResult.length && activeResult[0].values.length
|
|
166
|
+
? parseInt(activeResult[0].values[0][0], 10) || 0
|
|
167
|
+
: 0;
|
|
168
|
+
if (remaining === 0 || activeIdx >= remaining) {
|
|
169
|
+
database.run("UPDATE wallet_state SET value = '0' WHERE key = 'active_account_index'");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
89
172
|
function persistDb() {
|
|
90
173
|
if (!db)
|
|
91
174
|
return;
|
|
@@ -115,7 +198,12 @@ const walletToolSchemas = {
|
|
|
115
198
|
},
|
|
116
199
|
listAccounts: {
|
|
117
200
|
type: 'object',
|
|
118
|
-
properties: {
|
|
201
|
+
properties: {
|
|
202
|
+
archived: {
|
|
203
|
+
type: 'boolean',
|
|
204
|
+
description: 'If true, return archived accounts (rows whose mnemonic could not be recovered from the OS keychain at startup, kept in the DB for forensics). If false or omitted, returns only active (signable) accounts.'
|
|
205
|
+
}
|
|
206
|
+
},
|
|
119
207
|
required: []
|
|
120
208
|
},
|
|
121
209
|
switchAccount: {
|
|
@@ -172,9 +260,20 @@ const walletToolSchemas = {
|
|
|
172
260
|
// ── Tool Definitions ─────────────────────────────────────────────────────────
|
|
173
261
|
export class WalletManager {
|
|
174
262
|
// ── Database Helpers ───────────────────────────────────────────────────────
|
|
175
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Returns accounts from wallet.db.
|
|
265
|
+
* - includeArchived = false (default): only active rows (archived = 0).
|
|
266
|
+
* Used by every signing/lookup path and by wallet_list_accounts in its
|
|
267
|
+
* default mode.
|
|
268
|
+
* - includeArchived = true: only archived rows (archived = 1). Used by
|
|
269
|
+
* wallet_list_accounts { archived: true } for forensics.
|
|
270
|
+
*/
|
|
271
|
+
static async getAllAccounts(includeArchived = false) {
|
|
176
272
|
const database = await getDb();
|
|
177
|
-
const
|
|
273
|
+
const sql = includeArchived
|
|
274
|
+
? 'SELECT * FROM accounts WHERE archived = 1 ORDER BY id'
|
|
275
|
+
: 'SELECT * FROM accounts WHERE archived = 0 ORDER BY id';
|
|
276
|
+
const result = database.exec(sql);
|
|
178
277
|
if (result.length === 0)
|
|
179
278
|
return [];
|
|
180
279
|
const cols = result[0].columns;
|
|
@@ -422,7 +521,10 @@ export class WalletManager {
|
|
|
422
521
|
}
|
|
423
522
|
const database = await getDb();
|
|
424
523
|
// Check nickname uniqueness
|
|
425
|
-
|
|
524
|
+
// Nickname uniqueness is enforced only among ACTIVE accounts (the
|
|
525
|
+
// partial unique index idx_active_nickname). Archived rows with the
|
|
526
|
+
// same nickname don't conflict — the new account takes over the name.
|
|
527
|
+
const existing = database.exec('SELECT id FROM accounts WHERE nickname = ? AND archived = 0', [args.nickname]);
|
|
426
528
|
if (existing.length > 0 && existing[0].values.length > 0) {
|
|
427
529
|
throw new McpError(ErrorCode.InvalidParams, `Account with nickname "${args.nickname}" already exists`);
|
|
428
530
|
}
|
|
@@ -492,17 +594,20 @@ export class WalletManager {
|
|
|
492
594
|
}
|
|
493
595
|
// ── wallet_list_accounts ─────────────────────────────────────────
|
|
494
596
|
case 'wallet_list_accounts': {
|
|
495
|
-
const
|
|
496
|
-
const
|
|
597
|
+
const showArchived = args.archived === true;
|
|
598
|
+
const accounts = await WalletManager.getAllAccounts(showArchived);
|
|
599
|
+
const activeIdx = showArchived ? -1 : await WalletManager.getActiveIndex();
|
|
497
600
|
return {
|
|
498
601
|
content: [{
|
|
499
602
|
type: 'text',
|
|
500
603
|
text: JSON.stringify({
|
|
604
|
+
archived: showArchived,
|
|
501
605
|
activeIndex: activeIdx,
|
|
502
606
|
count: accounts.length,
|
|
503
607
|
accounts: accounts.map((a, i) => ({
|
|
504
608
|
index: i,
|
|
505
|
-
active: i === activeIdx,
|
|
609
|
+
active: !showArchived && i === activeIdx,
|
|
610
|
+
archived: showArchived,
|
|
506
611
|
nickname: a.nickname,
|
|
507
612
|
address: a.address,
|
|
508
613
|
publicKey: a.public_key,
|
|
@@ -745,7 +850,7 @@ WalletManager.walletTools = [
|
|
|
745
850
|
},
|
|
746
851
|
{
|
|
747
852
|
name: 'wallet_list_accounts',
|
|
748
|
-
description: 'List
|
|
853
|
+
description: 'List wallet accounts with their nicknames and addresses. By default returns only ACTIVE accounts (signable). Pass { archived: true } to return ARCHIVED accounts instead — these are accounts whose mnemonic could not be recovered from the OS keychain at startup (e.g., wallet.db moved to a new machine without keychain, or fresh Docker install over an existing DB). Archived rows stay in the DB for forensics but cannot sign; their nicknames are freed for reuse by new active accounts.',
|
|
749
854
|
inputSchema: withCommonParams(walletToolSchemas.listAccounts),
|
|
750
855
|
},
|
|
751
856
|
{
|