@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 +17 -5
- package/dist/index.js +130 -17
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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)
|
|
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`).
|
|
75
|
-
|
|
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 <
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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:
|