@formstr/mcp 0.3.2 → 0.4.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,9 +21,27 @@ npx -y @formstr/mcp login
21
21
  ```
22
22
 
23
23
  Subcommands: `formstr-mcp login` · `formstr-mcp whoami` · `formstr-mcp accounts` ·
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.
24
+ `formstr-mcp switch <npub>` · `formstr-mcp logout` · `formstr-mcp version` ·
25
+ `formstr-mcp help` · `formstr-mcp` (run the stdio server, the default). Run
26
+ `formstr-mcp help` (or `-h`) for the full usage.
27
+
28
+ ## Version & updates
29
+
30
+ `formstr-mcp version` (or `-v` / `--version`) prints the installed version and checks the
31
+ npm registry for a newer release:
32
+
33
+ ```text
34
+ $ formstr-mcp version
35
+ @formstr/mcp 0.4.0
36
+ Update available: 0.5.0 (you have 0.4.0).
37
+ Upgrade: npm install -g @formstr/mcp@latest
38
+ Or just re-run via: npx -y @formstr/mcp@latest
39
+ ```
40
+
41
+ The update check is best-effort — if you're offline or the registry is unreachable it
42
+ prints the installed version and a note, never an error. If you run the server via
43
+ `npx -y @formstr/mcp` you already get the latest published version on each launch; pin a
44
+ version (`@formstr/mcp@0.4.0`) in your host config if you'd rather control upgrades.
27
45
 
28
46
  ## Sign-in
29
47
 
@@ -71,6 +89,82 @@ After `login`, no key belongs in the config:
71
89
  Add `"--allow-writes"` to `args` to enable the gated (destructive/outward) tools, and
72
90
  `"--relays", "wss://a,wss://b"` to override relays.
73
91
 
92
+ ### Passing the ncryptsec passphrase
93
+
94
+ If your active account is an `ncryptsec` key (Create / Import login), the server needs its
95
+ passphrase to unlock at boot. An MCP host spawns the server with stdin wired to the
96
+ JSON-RPC channel, so it **can't prompt** — supply the passphrase through an `"env"` block in
97
+ the server entry of your MCP config (`mcp_config.json`, `claude_desktop_config.json`, Cursor's
98
+ `~/.cursor/mcp.json`, etc.):
99
+
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "formstr": {
104
+ "command": "npx",
105
+ "args": ["-y", "@formstr/mcp"],
106
+ "env": {
107
+ "FORMSTR_MCP_NCRYPTSEC_PASSPHRASE": "your-passphrase-here"
108
+ }
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ The host hands that value to the server as an environment variable at startup — it never
115
+ enters the chat transcript. Each account has its own passphrase, so this unlocks whichever
116
+ one is **active** (set it with `formstr-mcp switch <npub>`).
117
+
118
+ > **Tip:** prefer not to keep a passphrase in a config file? Use a **NIP-46 (bunker)**
119
+ > account instead — it reconnects from its stored session and needs **no** passphrase, so
120
+ > the config can stay secret-free. Run `formstr-mcp switch <npub>` to a bunker account.
121
+
122
+ ## Using with Ollama (local models)
123
+
124
+ Ollama isn't an MCP client — it just runs the model. To drive this server with a **local**
125
+ model you need an MCP **host** that uses Ollama as its backend. A good, actively-maintained
126
+ option is [**Goose**](https://block.github.io/goose/) (open-source agent by Block; CLI +
127
+ desktop), which has both first-class Ollama support and native stdio MCP extensions.
128
+
129
+ **1. Pull a tool-calling-capable model and start Ollama:**
130
+
131
+ ```bash
132
+ ollama pull qwen2.5 # llama3.1 / 3.2, mistral, … also work — the model MUST support tools
133
+ ollama serve # serves the API on http://localhost:11434
134
+ ```
135
+
136
+ A model **without** tool/function-calling support can chat but can't invoke this server's
137
+ tools (`list_forms`, `create_form`, …), so don't pick one of those.
138
+
139
+ **2. Sign in to formstr once** (stores your key in the keystore):
140
+
141
+ ```bash
142
+ npx -y @formstr/mcp login
143
+ ```
144
+
145
+ **3. Point Goose at Ollama** — `goose configure` → _Configure Providers_ → **Ollama**, then
146
+ enter the host (`http://localhost:11434`) and pick your model.
147
+
148
+ **4. Add this server as an extension** — `goose configure` → _Add Extension_ → **Command-line
149
+ Extension**, then answer the prompts:
150
+
151
+ | Prompt | Value |
152
+ | --------------------- | ------------------------------------------------------------- |
153
+ | Name | `formstr` |
154
+ | Command | `npx -y @formstr/mcp` (add `--allow-writes` to enable writes) |
155
+ | Timeout (secs) | `300` |
156
+ | Environment variables | `FORMSTR_MCP_NCRYPTSEC_PASSPHRASE` = _your passphrase_ |
157
+
158
+ Goose passes that env var to the server when it spawns it (so it unlocks headlessly), and
159
+ saves the extension to `~/.config/goose/config.yaml`. Then just run `goose` (or `goose
160
+ session`) and ask it to work with your forms.
161
+
162
+ > **Bunker accounts need no passphrase** — skip the env-variable step and `formstr-mcp switch
163
+ <npub>` to a NIP-46 account (see the tip above); the extension then stores no secret.
164
+
165
+ The same approach works with any other Ollama-backed MCP host: point it at
166
+ `npx -y @formstr/mcp` and supply the passphrase through that host's env mechanism.
167
+
74
168
  ## Headless / unattended
75
169
 
76
170
  Run `formstr-mcp login` once interactively to populate the keystore, then run the server
package/dist/index.js CHANGED
@@ -27493,12 +27493,23 @@ async function unlockNcryptsec(signer, account, deps) {
27493
27493
  }
27494
27494
 
27495
27495
  // src/cli.ts
27496
- var SUBCOMMANDS = /* @__PURE__ */ new Set(["login", "logout", "whoami", "accounts", "switch", "help"]);
27496
+ var SUBCOMMANDS = /* @__PURE__ */ new Set([
27497
+ "login",
27498
+ "logout",
27499
+ "whoami",
27500
+ "accounts",
27501
+ "switch",
27502
+ "help",
27503
+ "version"
27504
+ ]);
27497
27505
  function parseCli(argv) {
27498
27506
  const rest = [...argv];
27499
27507
  if (rest.includes("-h") || rest.includes("--help")) {
27500
27508
  return { command: "help", allowWrites: false };
27501
27509
  }
27510
+ if (rest.includes("-v") || rest.includes("--version")) {
27511
+ return { command: "version", allowWrites: false };
27512
+ }
27502
27513
  let command = "run";
27503
27514
  if (rest[0] && SUBCOMMANDS.has(rest[0])) {
27504
27515
  command = rest.shift();
@@ -27539,6 +27550,7 @@ function helpText() {
27539
27550
  " accounts List stored accounts ('*' marks the active one).",
27540
27551
  " switch <npub> Set the active account (accepts an npub or hex pubkey).",
27541
27552
  " help Show this help (also -h, --help).",
27553
+ " version Print the installed version and check for an update (also -v, --version).",
27542
27554
  "",
27543
27555
  "Flags:",
27544
27556
  " --allow-writes Enable gated write tools (update / delete / share / submit).",
@@ -27547,7 +27559,28 @@ function helpText() {
27547
27559
  "",
27548
27560
  "Env:",
27549
27561
  " FORMSTR_MCP_NCRYPTSEC_PASSPHRASE Unlock the active ncryptsec account at boot.",
27550
- " FORMSTR_MCP_PASSPHRASE Encrypt the keystore file (keychain-less hosts)."
27562
+ " FORMSTR_MCP_PASSPHRASE Encrypt the keystore file (keychain-less hosts).",
27563
+ "",
27564
+ "Setting the passphrase in your MCP host config:",
27565
+ " When an MCP host (Claude Desktop/Code, Cursor, \u2026) spawns the server, stdin is the",
27566
+ " JSON-RPC channel, so it can't prompt for your ncryptsec passphrase. Pass it via an",
27567
+ ' "env" block in the server entry of your MCP config (e.g. mcp_config.json /',
27568
+ " claude_desktop_config.json) so the host hands it to the server at startup:",
27569
+ "",
27570
+ " {",
27571
+ ' "mcpServers": {',
27572
+ ' "formstr": {',
27573
+ ' "command": "npx",',
27574
+ ' "args": ["-y", "@formstr/mcp"],',
27575
+ ' "env": {',
27576
+ ' "FORMSTR_MCP_NCRYPTSEC_PASSPHRASE": "your-passphrase-here"',
27577
+ " }",
27578
+ " }",
27579
+ " }",
27580
+ " }",
27581
+ "",
27582
+ " Tip: a NIP-46 (bunker) account needs no passphrase \u2014 `switch` to one and the config",
27583
+ ' can stay secret-free. Add "--allow-writes" to args to enable gated write tools.'
27551
27584
  ].join("\n");
27552
27585
  }
27553
27586
 
@@ -32431,19 +32464,27 @@ async function fetchMyForms() {
32431
32464
  return summary;
32432
32465
  }).filter((s) => s !== null);
32433
32466
  }
32467
+ var MY_FORMS_LIST_READ_ATTEMPTS = 3;
32468
+ async function readMyFormsEntriesForWrite(signer, userPubkey, relays) {
32469
+ let listEvent = null;
32470
+ for (let attempt = 0; attempt < MY_FORMS_LIST_READ_ATTEMPTS; attempt++) {
32471
+ listEvent = await fetchLatestMyFormsEvent(relays, userPubkey);
32472
+ if (listEvent?.content) break;
32473
+ }
32474
+ if (!listEvent?.content) return [];
32475
+ try {
32476
+ return await decryptListEntries(signer, userPubkey, listEvent.content);
32477
+ } catch (err) {
32478
+ throw new Error(
32479
+ `Could not read your existing forms list to add this form, so it was left unchanged to avoid losing your other forms. The form itself was published \u2014 retry to add it. (${err instanceof Error ? err.message : String(err)})`
32480
+ );
32481
+ }
32482
+ }
32434
32483
  async function appendToMyFormsList(formPubkey, formId, relay, signingKeyHex, viewKeyHex) {
32435
32484
  const signer = await signerManager.getSigner();
32436
32485
  const userPubkey = await signer.getPublicKey();
32437
32486
  const relays = relayManager.getRelaysForModule("forms");
32438
- const existing = await fetchLatestMyFormsEvent(relays, userPubkey);
32439
- let entries = [];
32440
- if (existing?.content) {
32441
- try {
32442
- entries = await decryptListEntries(signer, userPubkey, existing.content);
32443
- } catch {
32444
- entries = [];
32445
- }
32446
- }
32487
+ let entries = await readMyFormsEntriesForWrite(signer, userPubkey, relays);
32447
32488
  entries = entries.map(
32448
32489
  (e) => e[0] === "f" && e.length < 4 ? ["f", e[1] ?? "", e[2] ?? "", ""] : e
32449
32490
  );
@@ -32627,9 +32668,13 @@ async function fetchFormSummaryFromRef(pubkey, formId) {
32627
32668
  };
32628
32669
  }
32629
32670
  async function importForm(summary) {
32630
- const current = await fetchMyForms();
32631
- if (current.some((f) => f.id === summary.id && f.pubkey === summary.pubkey)) return;
32632
- await saveToMyForms([...current, summary]);
32671
+ await appendToMyFormsList(
32672
+ summary.pubkey,
32673
+ summary.id,
32674
+ summary.relay ?? "",
32675
+ summary.signingKey,
32676
+ summary.viewKey
32677
+ );
32633
32678
  }
32634
32679
  function parseFormEvent(event, decryptedRows) {
32635
32680
  const merged = decryptedRows ? [...event.tags, ...decryptedRows] : event.tags;
@@ -46563,6 +46608,95 @@ async function startStdio(ctx) {
46563
46608
  await server.connect(transport);
46564
46609
  }
46565
46610
 
46611
+ // src/version.ts
46612
+ var import_node_fs2 = require("fs");
46613
+ var import_node_path2 = require("path");
46614
+ var PACKAGE_NAME = "@formstr/mcp";
46615
+ function readInstalledVersion() {
46616
+ try {
46617
+ const pkg = JSON.parse((0, import_node_fs2.readFileSync)((0, import_node_path2.join)(__dirname, "..", "package.json"), "utf8"));
46618
+ return pkg.version ?? "0.0.0";
46619
+ } catch {
46620
+ return "0.0.0";
46621
+ }
46622
+ }
46623
+ function parseSemver(version2) {
46624
+ const cleaned = version2.trim().replace(/^v/i, "").split("+")[0];
46625
+ const [core, pre] = cleaned.split("-", 2);
46626
+ const release = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
46627
+ while (release.length < 3) release.push(0);
46628
+ return { release, prerelease: pre ? pre.split(".") : [] };
46629
+ }
46630
+ function compareIdentifiers(a, b) {
46631
+ const na = /^\d+$/.test(a);
46632
+ const nb = /^\d+$/.test(b);
46633
+ if (na && nb) return Number(a) - Number(b) < 0 ? -1 : Number(a) === Number(b) ? 0 : 1;
46634
+ if (na) return -1;
46635
+ if (nb) return 1;
46636
+ return a < b ? -1 : a > b ? 1 : 0;
46637
+ }
46638
+ function compareVersions(a, b) {
46639
+ const va = parseSemver(a);
46640
+ const vb = parseSemver(b);
46641
+ for (let i4 = 0; i4 < 3; i4++) {
46642
+ if (va.release[i4] !== vb.release[i4]) return va.release[i4] < vb.release[i4] ? -1 : 1;
46643
+ }
46644
+ if (va.prerelease.length === 0 && vb.prerelease.length === 0) return 0;
46645
+ if (va.prerelease.length === 0) return 1;
46646
+ if (vb.prerelease.length === 0) return -1;
46647
+ const len = Math.max(va.prerelease.length, vb.prerelease.length);
46648
+ for (let i4 = 0; i4 < len; i4++) {
46649
+ const ia = va.prerelease[i4];
46650
+ const ib = vb.prerelease[i4];
46651
+ if (ia === void 0) return -1;
46652
+ if (ib === void 0) return 1;
46653
+ const c = compareIdentifiers(ia, ib);
46654
+ if (c !== 0) return c < 0 ? -1 : 1;
46655
+ }
46656
+ return 0;
46657
+ }
46658
+ async function fetchLatestVersion(pkgName, opts = {}) {
46659
+ const fetchImpl = opts.fetchImpl ?? fetch;
46660
+ const timeoutMs = opts.timeoutMs ?? 3e3;
46661
+ const controller = new AbortController();
46662
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
46663
+ try {
46664
+ const res = await fetchImpl(`https://registry.npmjs.org/${pkgName}/latest`, {
46665
+ signal: controller.signal,
46666
+ // The abbreviated metadata document is far smaller than the full packument.
46667
+ headers: { accept: "application/vnd.npm.install-v1+json" }
46668
+ });
46669
+ if (!res.ok) return null;
46670
+ const body = await res.json();
46671
+ return typeof body.version === "string" ? body.version : null;
46672
+ } catch {
46673
+ return null;
46674
+ } finally {
46675
+ clearTimeout(timer);
46676
+ }
46677
+ }
46678
+ function formatVersionReport(installed, latest) {
46679
+ const head = `${PACKAGE_NAME} ${installed}`;
46680
+ if (!latest) {
46681
+ return `${head}
46682
+ (could not check for updates \u2014 offline or registry unreachable)`;
46683
+ }
46684
+ const cmp = compareVersions(installed, latest);
46685
+ if (cmp < 0) {
46686
+ return [
46687
+ head,
46688
+ `Update available: ${latest} (you have ${installed}).`,
46689
+ `Upgrade: npm install -g ${PACKAGE_NAME}@latest`,
46690
+ `Or just re-run via: npx -y ${PACKAGE_NAME}@latest`
46691
+ ].join("\n");
46692
+ }
46693
+ if (cmp > 0) {
46694
+ return `${head}
46695
+ (ahead of the latest published release ${latest})`;
46696
+ }
46697
+ return `${head} (latest)`;
46698
+ }
46699
+
46566
46700
  // src/index.ts
46567
46701
  async function runServer(cli) {
46568
46702
  const cfg = resolveConfig(cli, process.env);
@@ -46653,6 +46787,12 @@ async function main() {
46653
46787
  case "help":
46654
46788
  console.error(helpText());
46655
46789
  return cli.command;
46790
+ case "version": {
46791
+ const installed = readInstalledVersion();
46792
+ const latest = await fetchLatestVersion(PACKAGE_NAME);
46793
+ console.log(formatVersionReport(installed, latest));
46794
+ return cli.command;
46795
+ }
46656
46796
  case "run":
46657
46797
  default:
46658
46798
  await runServer(cli);