@appsoftwareltd/etherpk-mcp 0.1.0 → 0.1.2

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
@@ -16,18 +16,30 @@ Open the graph in EtherPK, pick **Settings → Agents**, and copy the two comman
16
16
  server and graph filled in. They are:
17
17
 
18
18
  ```sh
19
- # Once per computer. Prompts for an account-wide Personal Access Token (from your Sync
20
- # Server's portal, Account → Tokens), then shows a short code to confirm in any unlocked
21
- # EtherPK tab - the same step as adding a phone.
22
- npx @appsoftwareltd/etherpk-mcp login --server https://sync.example.com
19
+ # Once per computer. Prompts for an account-wide Personal Access Token (make one at
20
+ # https://server.etherpk.com/account/tokens, also reachable from "Access tokens" in
21
+ # EtherPK's account menu), then shows a short code and the address of your EtherPK:
22
+ # open EtherPK there in a browser where you're signed in with your graphs unlocked -
23
+ # any page will do - and confirm the code. The same step as adding a phone.
24
+ npx @appsoftwareltd/etherpk-mcp login --server https://server.etherpk.com
25
+
26
+ # Self-hosting your own Sync Server? Give its address instead:
27
+ # npx @appsoftwareltd/etherpk-mcp login --server https://sync.your-domain.example
28
+
29
+ # No EtherPK to hand on this computer (a server you reach over SSH, say)? Press r while
30
+ # login is waiting, or use your Recovery Code from the start:
31
+ # npx @appsoftwareltd/etherpk-mcp login --server https://server.etherpk.com --recovery-code
23
32
 
24
33
  # Tell the agent about the graph (Claude Code shown; the Agents tab has the others).
25
34
  claude mcp add etherpk -- npx @appsoftwareltd/etherpk-mcp serve --graph <graph id>
26
35
  ```
27
36
 
28
37
  `npx @appsoftwareltd/etherpk-mcp graphs` lists the graphs the token can reach, by name and id.
29
- On a computer with no EtherPK tab to hand, `login --recovery-code` takes your Recovery Code
30
- instead (or `ETHERPK_RECOVERY_CODE` for a scripted setup; `ETHERPK_PAT` likewise).
38
+ For a scripted setup, `ETHERPK_PAT` and `ETHERPK_RECOVERY_CODE` stand in for the prompts.
39
+
40
+ Every command runs through `npx`, which fetches the package but never puts `etherpk-mcp` on your
41
+ PATH. If you'd rather type the short form, `npm install -g @appsoftwareltd/etherpk-mcp` once and
42
+ drop the `npx @appsoftwareltd/` prefix; the program's own hints follow whichever way you ran it.
31
43
 
32
44
  ## What the agent gets
33
45
 
@@ -36,11 +48,43 @@ unique old→new replacement that merges with anyone typing elsewhere on the pag
36
48
  `append_document` (a page, or a day's journal entry, created if needed) and `create_page`.
37
49
  One running instance serves one graph.
38
50
 
39
- ## Stopping it
51
+ ## What it keeps on your computer
52
+
53
+ - **Keys**: `~/.config/etherpk/mcp.json`, readable only by your user - the same trust as a browser
54
+ you've signed in on.
55
+ - **A cache of each graph you serve**: `~/.cache/etherpk/mcp/`, so a restart catches up on what
56
+ changed instead of downloading everything again. It holds your notes readably, like a signed-in
57
+ browser's own storage does. It is never the source of truth: on every start the Sync Server is
58
+ asked what moved and only that is fetched, edits made elsewhere arrive as they happen, and a
59
+ newer version of this program discards a cache it no longer understands and rebuilds it.
40
60
 
41
61
  Revoke the token at the portal to cut the agent off, and `npx @appsoftwareltd/etherpk-mcp logout`
42
- to forget the token, keys and cached graphs on that computer. The keys live in
43
- `~/.config/etherpk/mcp.json` and the cached graphs under `~/.cache/etherpk/mcp/`, both readable
44
- only by your user - the same trust as a browser you have signed in on.
62
+ to forget the keys and delete the cache on that computer.
63
+
64
+ ## Building and publishing
65
+
66
+ From the EtherPK monorepo (`apps/mcp`; the bundle compiles the Client's own sync, crypto and
67
+ index code in through a `$lib` alias, so it builds from the repo, not from this folder alone):
68
+
69
+ ```sh
70
+ pnpm install # once, at the repo root
71
+ pnpm --filter @appsoftwareltd/etherpk-mcp check # type-check
72
+ pnpm --filter @appsoftwareltd/etherpk-mcp test # unit tests (loopback relay, no server)
73
+ pnpm --filter @appsoftwareltd/etherpk-mcp build # dist/main.js; run it with node dist/main.js
74
+ ```
75
+
76
+ To release: bump `version` in `package.json` (the CLI reads its version from there), then from
77
+ `apps/mcp` in a real terminal (both the login and the publish need a browser or one-time code,
78
+ which needs a TTY):
79
+
80
+ ```sh
81
+ npm whoami || npm login # only if not already signed in (or the token has expired); needs publish rights on @appsoftwareltd
82
+ pnpm publish --access public --no-git-checks # prepack runs the build; pnpm rewrites workspace:* deps
83
+ npx -y @appsoftwareltd/etherpk-mcp@<version> --version # verify, once the registry lists it (a few minutes)
84
+ ```
85
+
86
+ The end-to-end specs in `tests-server/agents/` build and drive this bundle against a real Sync
87
+ Server; the technical notes are in the repo under `docs/docs/technical/Headless Client.md`.
45
88
 
46
- Full guide: <https://docs.etherpk.com/using-ai-agents-with-your-notes>.
89
+ Full guide, including how the cache stays current and what an agent can and can't do:
90
+ <https://docs.etherpk.com/using-ai-agents-with-your-notes>.
package/dist/main.js CHANGED
@@ -15,6 +15,61 @@ import { parser } from "@lezer/markdown";
15
15
  import { parse, stringify } from "yaml";
16
16
  import { deserialize, serialize } from "node:v8";
17
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ var package_default = {
19
+ name: "@appsoftwareltd/etherpk-mcp",
20
+ version: "0.1.2",
21
+ license: "Elastic-2.0",
22
+ description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
23
+ type: "module",
24
+ "private": false,
25
+ bin: { "etherpk-mcp": "./bin/etherpk-mcp.js" },
26
+ files: ["bin", "dist"],
27
+ scripts: {
28
+ "build": "vite build",
29
+ "check": "tsc -p tsconfig.json --noEmit",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "prepack": "pnpm build"
33
+ },
34
+ dependencies: {
35
+ "@lezer/markdown": "^1.6.4",
36
+ "@modelcontextprotocol/sdk": "^1.30.0",
37
+ "@noble/curves": "^2.2.0",
38
+ "@noble/hashes": "^2.2.0",
39
+ "@sqlite.org/sqlite-wasm": "3.53.0-build1",
40
+ "fake-indexeddb": "^6.2.5",
41
+ "lib0": "^0.2.117",
42
+ "y-protocols": "^1.0.7",
43
+ "yaml": "^2.9.0",
44
+ "yjs": "^13.6.31",
45
+ "zod": "^4.3.6"
46
+ },
47
+ devDependencies: {
48
+ "@appsoftwareltd/etherpk-shared": "workspace:*",
49
+ "@codemirror/view": "^6.43.0",
50
+ "@types/node": "^25.6.0",
51
+ "typescript": "^6.0.2",
52
+ "vite": "^8.0.8",
53
+ "vitest": "^4.1.4"
54
+ },
55
+ publishConfig: { "access": "public" },
56
+ homepage: "https://docs.etherpk.com/using-ai-agents-with-your-notes",
57
+ repository: {
58
+ "type": "git",
59
+ "url": "https://github.com/appsoftwareltd/etherpk",
60
+ "directory": "apps/mcp"
61
+ },
62
+ keywords: [
63
+ "etherpk",
64
+ "mcp",
65
+ "model-context-protocol",
66
+ "knowledge-graph",
67
+ "notes",
68
+ "agent"
69
+ ],
70
+ engines: { "node": ">=22" }
71
+ };
72
+ //#endregion
18
73
  //#region ../client/src/lib/crypto/bytes.ts
19
74
  /**
20
75
  * Byte-array and encoding primitives for the crypto core. No cryptography here.
@@ -618,7 +673,8 @@ async function connectAccount(config) {
618
673
  id: me.principal.id,
619
674
  email: me.principal.email,
620
675
  name: me.principal.name
621
- }
676
+ },
677
+ clientUrl: me.clientUrl ?? null
622
678
  };
623
679
  }
624
680
  /**
@@ -858,6 +914,7 @@ z.strictObject({
858
914
  image: httpsUrl.nullable()
859
915
  }),
860
916
  authentication: syncAuthenticationSchema,
917
+ clientUrl: httpsUrl.optional(),
861
918
  entitlement: z.strictObject({
862
919
  plan: z.string().min(1).max(64),
863
920
  status: serviceEntitlementSchema.shape.status,
@@ -1976,37 +2033,50 @@ function serializeClientMessage(message) {
1976
2033
  * still reads on a white surface, and light enough that dark label text works on all of them
1977
2034
  * in both themes. The selection tint is the same colour at 40% alpha (`66`) — the old 20%
1978
2035
  * tint of a primary was already faint; a pastel at 20% disappears on white.
2036
+ *
2037
+ * The same eight are offered as the ready-made toolbar colours in Graph Settings (ADR 0071):
2038
+ * one palette, so a graph's bar and a member's caret speak the same language. The label is
2039
+ * for a swatch there; presence itself never shows it (`label`, not `name`: an entry is
2040
+ * spread into a {@link PresenceIdentity}, whose `name` is the pseudonym).
1979
2041
  */
1980
2042
  var PRESENCE_PALETTE = [
1981
2043
  {
2044
+ label: "Sky",
1982
2045
  color: "#7dd3fc",
1983
2046
  colorLight: "#7dd3fc66"
1984
2047
  },
1985
2048
  {
2049
+ label: "Amber",
1986
2050
  color: "#fcd34d",
1987
2051
  colorLight: "#fcd34d66"
1988
2052
  },
1989
2053
  {
2054
+ label: "Mint",
1990
2055
  color: "#6ee7b7",
1991
2056
  colorLight: "#6ee7b766"
1992
2057
  },
1993
2058
  {
2059
+ label: "Salmon",
1994
2060
  color: "#fca5a5",
1995
2061
  colorLight: "#fca5a566"
1996
2062
  },
1997
2063
  {
2064
+ label: "Lavender",
1998
2065
  color: "#c4b5fd",
1999
2066
  colorLight: "#c4b5fd66"
2000
2067
  },
2001
2068
  {
2069
+ label: "Pink",
2002
2070
  color: "#f9a8d4",
2003
2071
  colorLight: "#f9a8d466"
2004
2072
  },
2005
2073
  {
2074
+ label: "Peach",
2006
2075
  color: "#fdba74",
2007
2076
  colorLight: "#fdba7466"
2008
2077
  },
2009
2078
  {
2079
+ label: "Lime",
2010
2080
  color: "#bef264",
2011
2081
  colorLight: "#bef26466"
2012
2082
  }
@@ -7921,25 +7991,39 @@ async function pollDeviceApproval(api, request) {
7921
7991
  *
7922
7992
  * Pure over an injected clock and output so the flow is testable without a terminal.
7923
7993
  */
7994
+ /** The user left the approval wait on purpose (the signal fired); nothing went wrong. */
7995
+ var ApprovalAbandoned = class extends Error {
7996
+ constructor() {
7997
+ super("Approval abandoned.");
7998
+ this.name = "ApprovalAbandoned";
7999
+ }
8000
+ };
7924
8001
  /** Approval rows expire server-side after ten minutes; poll a little longer and then give up. */
7925
8002
  var APPROVAL_TIMEOUT_MS = 11 * 6e4;
7926
8003
  var APPROVAL_POLL_MS = 2e3;
7927
8004
  async function unlockByDeviceApproval(api, io) {
7928
8005
  const request = await beginDeviceApproval(api);
8006
+ const where = io.clientUrl ? `open EtherPK at ${io.clientUrl}` : "open EtherPK in a browser";
7929
8007
  io.say("");
7930
- io.say(`Approve this device from EtherPK: open any unlocked tab of this account and confirm the code`);
8008
+ io.say(`To approve this device, ${where} (any page - it need not be a note) signed in to this account`);
8009
+ io.say("with its graphs unlocked. A prompt will show a code; confirm it matches this one:");
7931
8010
  io.say("");
7932
8011
  io.say(` ${request.sas}`);
7933
8012
  io.say("");
7934
8013
  io.say("Waiting (up to ten minutes)…");
8014
+ const abandon = async () => {
8015
+ await api.cancelDeviceApproval(request.id).catch(() => {});
8016
+ throw new ApprovalAbandoned();
8017
+ };
7935
8018
  const deadline = Date.now() + APPROVAL_TIMEOUT_MS;
7936
8019
  while (Date.now() < deadline) {
7937
- if (io.signal?.aborted) throw new Error("Cancelled.");
8020
+ if (io.signal?.aborted) await abandon();
7938
8021
  const outcome = await pollDeviceApproval(api, request);
7939
8022
  if (outcome.state === "unlocked") return outcome.deviceKey;
7940
8023
  if (outcome.state === "rejected") throw new Error("The approval was rejected in EtherPK.");
7941
8024
  if (outcome.state === "expired") throw new Error("The approval expired before it was confirmed. Run login again.");
7942
8025
  await io.sleep(APPROVAL_POLL_MS);
8026
+ if (io.signal?.aborted) await abandon();
7943
8027
  }
7944
8028
  throw new Error("The approval was not confirmed in time. Run login again.");
7945
8029
  }
@@ -8314,21 +8398,29 @@ function createMcpServer(graph, info) {
8314
8398
  * `serve` speaks MCP over stdio, so everything for the human goes to stderr; stdout belongs
8315
8399
  * to the agent. `login` and `graphs` are interactive and print to stdout.
8316
8400
  */
8317
- var VERSION = "0.1.0";
8401
+ var VERSION = package_default.version;
8402
+ /**
8403
+ * How the user invokes this program, so every hint is one they can paste. Through npx - the way
8404
+ * the Agents tab, the README and the docs all say - the bin is never on their PATH, and an npx
8405
+ * run executes out of npm's `_npx` cache; a global install (`npm install -g`) runs from
8406
+ * anywhere else and does have `etherpk-mcp` on the PATH, so it gets the short spelling.
8407
+ */
8408
+ var CMD = /[\\/]_npx[\\/]/.test(process.argv[1] ?? "") ? "npx @appsoftwareltd/etherpk-mcp" : "etherpk-mcp";
8318
8409
  var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synced graph)
8319
8410
 
8320
- etherpk-mcp login --server <url> [--pat <token>] [--recovery-code]
8411
+ ${CMD} login --server <url> [--pat <token>] [--recovery-code]
8321
8412
  Sign this machine in as a device of your account. Prompts for a Personal Access
8322
8413
  Token (an account-wide one, from the Sync Server portal at <url>/account/tokens)
8323
- unless --pat or ETHERPK_PAT is given, then unlocks your keys by Device Approval
8324
- (confirm a code in any unlocked EtherPK tab) or, with --recovery-code, by typing
8325
- your Recovery Code (or ETHERPK_RECOVERY_CODE, for a scripted setup).
8326
- etherpk-mcp graphs
8414
+ unless --pat or ETHERPK_PAT is given, then unlocks your keys by Device Approval:
8415
+ open EtherPK in a browser signed in to the account with its graphs unlocked and
8416
+ confirm the code shown. Press r while waiting, or pass --recovery-code, to type
8417
+ your Recovery Code instead (or ETHERPK_RECOVERY_CODE, for a scripted setup).
8418
+ ${CMD} graphs
8327
8419
  List the synced graphs this account can reach, by name and id.
8328
- etherpk-mcp serve --graph <id or name>
8420
+ ${CMD} serve --graph <id or name>
8329
8421
  Serve one graph to an agent over stdio. For Claude Code:
8330
8422
  claude mcp add etherpk -- npx @appsoftwareltd/etherpk-mcp serve --graph <id>
8331
- etherpk-mcp logout
8423
+ ${CMD} logout
8332
8424
  Forget the token, keys and cached graphs on this machine.
8333
8425
 
8334
8426
  The config file is ${defaultConfigPath()} (override with ETHERPK_MCP_CONFIG); cached graphs live
@@ -8379,7 +8471,7 @@ async function ask(question, { secret = false } = {}) {
8379
8471
  }
8380
8472
  async function requireConfig(path) {
8381
8473
  const config = await readConfig(path);
8382
- if (!config) fail(`Not logged in on this machine. Run: etherpk-mcp login --server <url>`);
8474
+ if (!config) fail(`Not logged in on this machine. Run: ${CMD} login --server <url>`);
8383
8475
  return config;
8384
8476
  }
8385
8477
  async function login(args) {
@@ -8393,10 +8485,8 @@ async function login(args) {
8393
8485
  pat
8394
8486
  });
8395
8487
  console.log(`Signed in to ${server} as ${account.principal.email ?? account.principal.name ?? account.principal.id}.`);
8396
- const vaultKey = args["recovery-code"] ? await unlockByRecoveryCode(account.api, process.env.ETHERPK_RECOVERY_CODE ?? await ask("Recovery Code: ", { secret: true })) : await unlockByDeviceApproval(account.api, {
8397
- say: (line) => console.log(line),
8398
- sleep: (ms) => new Promise((r) => setTimeout(r, ms))
8399
- });
8488
+ const byRecoveryCode = async () => unlockByRecoveryCode(account.api, process.env.ETHERPK_RECOVERY_CODE ?? await ask("Recovery Code: ", { secret: true }));
8489
+ const vaultKey = args["recovery-code"] ? await byRecoveryCode() : await approveOrFallBack(account, byRecoveryCode);
8400
8490
  await writeConfig(path, {
8401
8491
  server,
8402
8492
  pat,
@@ -8409,8 +8499,55 @@ async function login(args) {
8409
8499
  vaultKey: toBase64Url(vaultKey)
8410
8500
  });
8411
8501
  }
8502
+ /**
8503
+ * Device Approval, with the Recovery Code one keypress away: a user who has no unlocked EtherPK
8504
+ * to hand should not have to Ctrl-C and re-read the help to find `--recovery-code`. On a
8505
+ * terminal, `r` during the wait abandons the approval (cancelled server-side) and asks for the
8506
+ * code instead; without a terminal the wait runs to its outcome.
8507
+ */
8508
+ async function approveOrFallBack(account, byRecoveryCode) {
8509
+ const abort = new AbortController();
8510
+ const io = {
8511
+ say: (line) => console.log(line),
8512
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
8513
+ signal: abort.signal,
8514
+ clientUrl: account.clientUrl
8515
+ };
8516
+ const stdin = process.stdin;
8517
+ const interactive = stdin.isTTY === true;
8518
+ const onKey = (chunk) => {
8519
+ const key = chunk.toString("utf8");
8520
+ if (key === "r" || key === "R") abort.abort();
8521
+ if (key === "") {
8522
+ console.log("");
8523
+ process.exit(130);
8524
+ }
8525
+ };
8526
+ if (interactive) {
8527
+ console.log("(Press r to type your Recovery Code instead.)");
8528
+ stdin.setRawMode(true);
8529
+ stdin.resume();
8530
+ stdin.on("data", onKey);
8531
+ }
8532
+ let abandoned = false;
8533
+ try {
8534
+ return await unlockByDeviceApproval(account.api, io);
8535
+ } catch (error) {
8536
+ if (!(error instanceof ApprovalAbandoned)) throw error;
8537
+ abandoned = true;
8538
+ } finally {
8539
+ if (interactive) {
8540
+ stdin.off("data", onKey);
8541
+ stdin.setRawMode(false);
8542
+ stdin.pause();
8543
+ }
8544
+ }
8545
+ if (!abandoned) throw new Error("unreachable");
8546
+ console.log("Approval cancelled; unlocking with your Recovery Code instead.");
8547
+ return byRecoveryCode();
8548
+ }
8412
8549
  async function listGraphs(config) {
8413
- if (!config.vaultKey) fail("Keys are not unlocked on this machine. Run: etherpk-mcp login");
8550
+ if (!config.vaultKey) fail(`Keys are not unlocked on this machine. Run: ${CMD} login`);
8414
8551
  const account = await connectAccount(config);
8415
8552
  const vault = await openAccountVault(account.api, fromBase64Url(config.vaultKey));
8416
8553
  const graphs = await account.api.listGraphs();
@@ -8432,13 +8569,14 @@ async function listGraphs(config) {
8432
8569
  console.log(` ${record.id} ${label} [${record.role}]`);
8433
8570
  }
8434
8571
  console.log("");
8435
- console.log("Serve one to an agent with: etherpk-mcp serve --graph <id>");
8572
+ console.log(`Serve one to an agent with: ${CMD} serve --graph <id>`);
8573
+ console.log(`For Claude Code: claude mcp add etherpk -- ${CMD} serve --graph <id>`);
8436
8574
  }
8437
8575
  async function serve(args) {
8438
8576
  const wanted = args.graph?.trim();
8439
8577
  if (!wanted) fail("serve needs --graph <id or name>.");
8440
8578
  const config = await requireConfig(defaultConfigPath());
8441
- if (!config.vaultKey) fail("Keys are not unlocked on this machine. Run: etherpk-mcp login");
8579
+ if (!config.vaultKey) fail(`Keys are not unlocked on this machine. Run: ${CMD} login`);
8442
8580
  const account = await connectAccount(config);
8443
8581
  const vault = await openAccountVault(account.api, fromBase64Url(config.vaultKey));
8444
8582
  const graphs = await account.api.listGraphs();
@@ -8460,7 +8598,7 @@ async function serve(args) {
8460
8598
  break;
8461
8599
  }
8462
8600
  }
8463
- if (!graphId) fail(`No synced graph is named or identified by "${wanted}". Run: etherpk-mcp graphs`);
8601
+ if (!graphId) fail(`No synced graph is named or identified by "${wanted}". Run: ${CMD} graphs`);
8464
8602
  const { record, keyring } = resolveGraphById(graphs, vault, graphId);
8465
8603
  console.error(`etherpk-mcp: opening graph ${graphId} on ${account.serverBaseUrl}…`);
8466
8604
  const graph = await openHeadlessGraph({