@formstr/mcp 0.2.0 → 0.3.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/README.md CHANGED
@@ -21,7 +21,9 @@ npx -y @formstr/mcp login
21
21
  ```
22
22
 
23
23
  Subcommands: `formstr-mcp login` · `formstr-mcp whoami` · `formstr-mcp accounts` ·
24
- `formstr-mcp logout` · `formstr-mcp` (run the stdio server, the default).
24
+ `formstr-mcp switch <npub>` · `formstr-mcp logout` · `formstr-mcp help` ·
25
+ `formstr-mcp` (run the stdio server, the default). Run `formstr-mcp help` (or `-h`) for
26
+ the full usage.
25
27
 
26
28
  ## Sign-in
27
29
 
@@ -39,7 +41,11 @@ offers four methods:
39
41
  Linux Secret Service via `@napi-rs/keyring`). On hosts without a keychain (e.g. headless
40
42
  Linux), set `FORMSTR_MCP_PASSPHRASE` to use an AES-256-GCM encrypted file at
41
43
  `~/.config/formstr-mcp/keystore.enc` (mode `0600`). Multiple identities are supported
42
- (`formstr-mcp accounts` lists them); select one at boot with `--account <pubkey>`.
44
+ (`formstr-mcp accounts` lists them). Change the persisted active account with
45
+ `formstr-mcp switch <npub>`, or pick one for a single boot with `--account <npub>`; the
46
+ server follows the active account when neither is given, so switching accounts just works.
47
+ Both `switch` and `--account` accept either the `npub` (as shown by `accounts`) or the hex
48
+ pubkey.
43
49
 
44
50
  **Defense in depth:** even on the encrypted-file fallback the stored key is _also_ NIP-49
45
51
  encrypted, so recovering it needs **both** the keystore **and** the unlock passphrase.
@@ -71,8 +77,13 @@ Run `formstr-mcp login` once interactively to populate the keystore, then run th
71
77
  unattended. At boot the active account is unlocked headlessly:
72
78
 
73
79
  - **ncryptsec accounts** decrypt using `FORMSTR_MCP_NCRYPTSEC_PASSPHRASE` (the passphrase
74
- you set during `login`). Required for the `run` command when the active account is local.
75
- - **NIP-46 accounts** reconnect from their stored session — no passphrase needed.
80
+ you set during `login`). On an **interactive terminal** the server instead **prompts**
81
+ for it (and re-prompts up to 3× on a typo) — so the env var is only _required_ when an
82
+ MCP host spawns the server, since then stdin is the JSON-RPC channel and there's nobody
83
+ to prompt. Each account has its own passphrase.
84
+ - **NIP-46 accounts** reconnect from their stored session — no passphrase needed. This is
85
+ the simplest setup for a host: `formstr-mcp switch <npub>` to a bunker account and the
86
+ config needs no secret at all.
76
87
 
77
88
  | Variable | Meaning |
78
89
  | ---------------------------------- | ----------------------------------------------------------------- |
@@ -81,8 +92,9 @@ unattended. At boot the active account is unlocked headlessly:
81
92
  | `FORMSTR_MCP_KEYSTORE` | force `file` or `keychain` backend (optional) |
82
93
  | `FORMSTR_MCP_CONFIG_DIR` | keystore directory (default `~/.config/formstr-mcp`) |
83
94
  | `FORMSTR_RELAYS` | comma-separated relay override (optional) |
95
+ | `FORMSTR_MCP_DEBUG` | print full stack traces on fatal errors (set to `1`) |
84
96
 
85
- CLI flags: `--relays <wss://a,wss://b>`, `--allow-writes`, `--account <pubkey>`.
97
+ CLI flags: `--relays <wss://a,wss://b>`, `--allow-writes`, `--account <npub|hex>`.
86
98
  There is no plaintext-nsec path — a raw key is never read from env, flags, or a config file.
87
99
 
88
100
  ## Forms tools
package/dist/index.js CHANGED
@@ -24405,7 +24405,26 @@ async function loginImport(deps) {
24405
24405
  }
24406
24406
  async function loginBunker(deps) {
24407
24407
  const uri = (await deps.prompt("Paste the bunker:// URI: ")).trim();
24408
- await deps.signer.loginWithBunkerUri(uri, { pool: deps.pool, perms: NIP46_PERMS });
24408
+ try {
24409
+ await deps.signer.loginWithBunkerUri(uri, { pool: deps.pool, perms: NIP46_PERMS });
24410
+ } catch (err) {
24411
+ throw new Error(describeBunkerError(uri, err));
24412
+ }
24413
+ }
24414
+ function describeBunkerError(uri, err) {
24415
+ const raw = err instanceof Error ? err.message : String(err);
24416
+ const hasSecret = /[?&]secret=/.test(uri);
24417
+ const expected = "`bunker://<pubkey>?relay=wss://\u2026&secret=<token>`";
24418
+ if (/no secret/i.test(raw) || !hasSecret) {
24419
+ return "Could not pair with this bunker URI" + (hasSecret ? "" : " \u2014 it has no `secret=` token") + `, so the remote signer rejected the connection. A pairing URI must look like ${expected}. Copy the FULL connection URI (including \`&secret=\u2026\`) from your signer app (e.g. nsec.app \u2192 Connect app), and make sure nothing was truncated \u2014 the secret is often one-time, so generate a fresh connection if needed. Or use the QR flow: \`formstr-mcp login\` \u2192 \`q\`. (signer reported: ${raw})`;
24420
+ }
24421
+ if (/invalid bunker uri/i.test(raw)) {
24422
+ return `That doesn't look like a valid bunker URI. Expected ${expected}. (parser reported: ${raw})`;
24423
+ }
24424
+ if (/at least one relay/i.test(raw)) {
24425
+ return `This bunker URI has no relays. Expected ${expected}. (reported: ${raw})`;
24426
+ }
24427
+ return `Bunker login failed: ${raw}. Check that the URI includes a \`secret=\` token and at least one \`relay=\`, that the remote signer is online, or try the QR flow (\`formstr-mcp login\` \u2192 \`q\`).`;
24409
24428
  }
24410
24429
  async function loginNostrConnect(deps, log) {
24411
24430
  const relays = deps.relays?.length ? deps.relays : DEFAULT_NOSTRCONNECT_RELAYS;
@@ -24426,6 +24445,19 @@ function whoami(signer) {
24426
24445
  function listAccounts(signer) {
24427
24446
  return signer.listAccounts();
24428
24447
  }
24448
+ function findAccount(accounts, target) {
24449
+ return accounts.find((a) => a.npub === target || a.pubkey === target) ?? null;
24450
+ }
24451
+ async function doSwitch(signer, target) {
24452
+ const match = findAccount(listAccounts(signer), target);
24453
+ if (!match) {
24454
+ throw new Error(
24455
+ `No stored account matching "${target}". Run \`formstr-mcp accounts\` to list them.`
24456
+ );
24457
+ }
24458
+ await signer.switchAccount(match.pubkey);
24459
+ return match;
24460
+ }
24429
24461
 
24430
24462
  // src/auth/kvStore.ts
24431
24463
  var import_node_crypto = require("crypto");
@@ -27366,7 +27398,7 @@ async function bootstrap(input, deps = {}) {
27366
27398
  }
27367
27399
  async function selectAccount(signer, requested) {
27368
27400
  if (requested) {
27369
- const match = signer.listAccounts().find((a) => a.pubkey === requested);
27401
+ const match = findAccount(signer.listAccounts(), requested);
27370
27402
  if (!match) {
27371
27403
  throw new Error(
27372
27404
  `No stored account for --account ${requested}. Run \`formstr-mcp accounts\` to list them.`
@@ -27383,18 +27415,7 @@ async function selectAccount(signer, requested) {
27383
27415
  }
27384
27416
  async function unlock(signer, account, deps) {
27385
27417
  if (account.method === "ncryptsec") {
27386
- if (!account.ncryptsec) {
27387
- throw new Error(
27388
- "Stored ncryptsec account is missing its encrypted key. Run `formstr-mcp login`."
27389
- );
27390
- }
27391
- const passphrase = deps.passphrase ?? process.env.FORMSTR_MCP_NCRYPTSEC_PASSPHRASE ?? (deps.promptPassphrase ? await deps.promptPassphrase("Passphrase to unlock your key: ") : void 0);
27392
- if (!passphrase) {
27393
- throw new Error(
27394
- "Set FORMSTR_MCP_NCRYPTSEC_PASSPHRASE to unlock the ncryptsec account at boot (or run in an interactive terminal to be prompted)."
27395
- );
27396
- }
27397
- await signer.loginWithNcryptsec(account.ncryptsec, passphrase);
27418
+ await unlockNcryptsec(signer, account, deps);
27398
27419
  return;
27399
27420
  }
27400
27421
  if (account.method === "nip46") {
@@ -27414,31 +27435,108 @@ async function unlock(signer, account, deps) {
27414
27435
  `Account method "${account.method}" cannot be unlocked headlessly \u2014 use ncryptsec or nip46.`
27415
27436
  );
27416
27437
  }
27438
+ var MAX_PASSPHRASE_ATTEMPTS = 3;
27439
+ async function unlockNcryptsec(signer, account, deps) {
27440
+ if (!account.ncryptsec) {
27441
+ throw new Error(
27442
+ `Account ${account.npub} is stored as an ncryptsec but its encrypted key is missing. Re-add it with \`formstr-mcp login\`.`
27443
+ );
27444
+ }
27445
+ const ncryptsec = account.ncryptsec;
27446
+ const tryUnlock = async (passphrase) => {
27447
+ try {
27448
+ await signer.loginWithNcryptsec(ncryptsec, passphrase);
27449
+ return true;
27450
+ } catch {
27451
+ return false;
27452
+ }
27453
+ };
27454
+ const configured = deps.passphrase ?? process.env.FORMSTR_MCP_NCRYPTSEC_PASSPHRASE;
27455
+ if (configured) {
27456
+ if (await tryUnlock(configured)) return;
27457
+ if (!deps.promptPassphrase) {
27458
+ throw new Error(
27459
+ `The configured passphrase did not unlock account ${account.npub} \u2014 it is most likely the wrong passphrase for this account (each account has its own). Set FORMSTR_MCP_NCRYPTSEC_PASSPHRASE to the passphrase that matches this account, or switch to another with \`formstr-mcp switch <npub>\` (a nip46/bunker account needs no passphrase).`
27460
+ );
27461
+ }
27462
+ console.error(
27463
+ `formstr-mcp: the configured passphrase didn't unlock ${account.npub} \u2014 enter it manually.`
27464
+ );
27465
+ } else if (!deps.promptPassphrase) {
27466
+ throw new Error(
27467
+ `Cannot unlock account ${account.npub} (ncryptsec): no passphrase is available. FORMSTR_MCP_NCRYPTSEC_PASSPHRASE is not set and this process has no interactive terminal to prompt on \u2014 an MCP host runs the server with stdin wired to the JSON-RPC channel. Fix: add FORMSTR_MCP_NCRYPTSEC_PASSPHRASE to your MCP server config's \`env\`, or switch to a nip46/bunker account with \`formstr-mcp switch <npub>\` (resumes with no passphrase).`
27468
+ );
27469
+ }
27470
+ const promptPassphrase = deps.promptPassphrase;
27471
+ for (let attempt = 1; attempt <= MAX_PASSPHRASE_ATTEMPTS; attempt++) {
27472
+ const passphrase = await promptPassphrase(`Passphrase to unlock ${account.npub}: `);
27473
+ if (await tryUnlock(passphrase)) return;
27474
+ const left = MAX_PASSPHRASE_ATTEMPTS - attempt;
27475
+ if (left > 0) console.error(`formstr-mcp: incorrect passphrase, ${left} attempt(s) left.`);
27476
+ }
27477
+ throw new Error(
27478
+ `Incorrect passphrase for account ${account.npub} after ${MAX_PASSPHRASE_ATTEMPTS} attempts.`
27479
+ );
27480
+ }
27417
27481
 
27418
27482
  // src/cli.ts
27419
- var SUBCOMMANDS = /* @__PURE__ */ new Set(["login", "logout", "whoami", "accounts"]);
27483
+ var SUBCOMMANDS = /* @__PURE__ */ new Set(["login", "logout", "whoami", "accounts", "switch", "help"]);
27420
27484
  function parseCli(argv) {
27421
27485
  const rest = [...argv];
27486
+ if (rest.includes("-h") || rest.includes("--help")) {
27487
+ return { command: "help", allowWrites: false };
27488
+ }
27422
27489
  let command = "run";
27423
27490
  if (rest[0] && SUBCOMMANDS.has(rest[0])) {
27424
27491
  command = rest.shift();
27425
27492
  }
27426
27493
  let relays;
27427
27494
  let account;
27495
+ let target;
27428
27496
  let allowWrites = false;
27429
27497
  for (let i4 = 0; i4 < rest.length; i4++) {
27430
27498
  const arg = rest[i4];
27431
27499
  if (arg === "--relays") relays = splitRelays(rest[++i4]);
27432
27500
  else if (arg === "--allow-writes") allowWrites = true;
27433
27501
  else if (arg === "--account") account = rest[++i4];
27502
+ else if (!arg.startsWith("-") && target === void 0) target = arg;
27434
27503
  }
27435
- return { command, relays, allowWrites, account };
27504
+ return { command, relays, allowWrites, account, target };
27505
+ }
27506
+ function formatFatal(err, debug = false) {
27507
+ if (err instanceof Error) return debug && err.stack ? err.stack : err.message;
27508
+ return String(err);
27436
27509
  }
27437
27510
  function splitRelays(value) {
27438
27511
  if (!value) return void 0;
27439
27512
  const parts = value.split(",").map((s) => s.trim()).filter(Boolean);
27440
27513
  return parts.length ? parts : void 0;
27441
27514
  }
27515
+ function helpText() {
27516
+ return [
27517
+ "formstr-mcp \u2014 MCP server for the Formstr super-app (Nostr forms & more).",
27518
+ "",
27519
+ "Usage: formstr-mcp [command] [flags]",
27520
+ "",
27521
+ "Commands:",
27522
+ " run Run the stdio MCP server (default when no command is given).",
27523
+ " login Sign in (create / import / bunker URI / QR) and store the key.",
27524
+ " logout [npub] Remove a stored account (defaults to the active one).",
27525
+ " whoami Print the active account.",
27526
+ " accounts List stored accounts ('*' marks the active one).",
27527
+ " switch <npub> Set the active account (accepts an npub or hex pubkey).",
27528
+ " help Show this help (also -h, --help).",
27529
+ "",
27530
+ "Flags:",
27531
+ " --allow-writes Enable gated write tools (update / delete / share / submit).",
27532
+ " --account <npub|hex> Boot a specific account instead of the active one.",
27533
+ " --relays <a,b,\u2026> Override relays (comma-separated).",
27534
+ "",
27535
+ "Env:",
27536
+ " FORMSTR_MCP_NCRYPTSEC_PASSPHRASE Unlock the active ncryptsec account at boot.",
27537
+ " FORMSTR_MCP_PASSPHRASE Encrypt the keystore file (keychain-less hosts)."
27538
+ ].join("\n");
27539
+ }
27442
27540
 
27443
27541
  // src/config.ts
27444
27542
  function resolveConfig(cli, env) {
@@ -46448,6 +46546,19 @@ async function main() {
46448
46546
  }
46449
46547
  return cli.command;
46450
46548
  }
46549
+ case "switch": {
46550
+ if (!cli.target) {
46551
+ throw new Error(
46552
+ "Usage: formstr-mcp switch <npub|hex>. Run `formstr-mcp accounts` to list them."
46553
+ );
46554
+ }
46555
+ const account = await doSwitch(await buildMcpSigner(), cli.target);
46556
+ console.error(`formstr-mcp: active account is now ${account.npub} (${account.method}).`);
46557
+ return cli.command;
46558
+ }
46559
+ case "help":
46560
+ console.error(helpText());
46561
+ return cli.command;
46451
46562
  case "run":
46452
46563
  default:
46453
46564
  await runServer(cli);
@@ -46457,7 +46568,9 @@ async function main() {
46457
46568
  main().then((command) => {
46458
46569
  if (command !== "run") process.exit(0);
46459
46570
  }).catch((err) => {
46460
- console.error("formstr-mcp: fatal:", err instanceof Error ? err.message : err);
46571
+ const debug = !!(process.env.FORMSTR_MCP_DEBUG || process.env.DEBUG);
46572
+ console.error("formstr-mcp: fatal:", formatFatal(err, debug));
46573
+ if (!debug) console.error("formstr-mcp: (set FORMSTR_MCP_DEBUG=1 for a full stack trace)");
46461
46574
  process.exit(1);
46462
46575
  });
46463
46576
  /*! Bundled license information: