@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 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
- No user action is required. No prompts, no env vars, no migration logs.
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 all accounts with nicknames and addresses |
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.0',
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 UNIQUE NOT NULL,
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: ensure `mnemonic` column exists on pre-existing DBs.
49
- ensureMnemonicColumn(db);
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
- function ensureMnemonicColumn(database) {
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
- const mnemonic = new Entry(KEYCHAIN_SERVICE, address).getPassword();
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
- // No keychain entry for this address (or keychain unavailable). Leave
85
- // mnemonic NULL the row remains until the user re-imports or replaces it.
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
- static async getAllAccounts() {
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 result = database.exec('SELECT * FROM accounts ORDER BY id');
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
- const existing = database.exec('SELECT id FROM accounts WHERE nickname = ?', [args.nickname]);
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 accounts = await WalletManager.getAllAccounts();
496
- const activeIdx = await WalletManager.getActiveIndex();
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 all wallet accounts with their nicknames and addresses.',
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
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goplausible/algorand-mcp",
3
- "version": "4.3.0",
3
+ "version": "4.3.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },