@appsoftwareltd/etherpk-mcp 0.8.0 → 0.8.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/dist/main.js CHANGED
@@ -3,7 +3,7 @@ import { createInterface } from "node:readline/promises";
3
3
  import { availableParallelism, homedir, hostname, tmpdir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { parseArgs } from "node:util";
6
- import { access, chmod, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
6
+ import { access, chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
8
  import { x25519 } from "@noble/curves/ed25519.js";
9
9
  import "@noble/hashes/argon2.js";
@@ -29,9 +29,12 @@ import { languages } from "@codemirror/language-data";
29
29
  import MarkdownIt from "markdown-it";
30
30
  import katex from "katex";
31
31
  import Mustache from "mustache";
32
+ import { lookup } from "node:dns";
33
+ import { request } from "node:https";
34
+ import { BlockList, isIP } from "node:net";
32
35
  var package_default = {
33
36
  name: "@appsoftwareltd/etherpk-mcp",
34
- version: "0.8.0",
37
+ version: "0.8.1",
35
38
  license: "Elastic-2.0",
36
39
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
37
40
  type: "module",
@@ -52,20 +55,20 @@ var package_default = {
52
55
  "@huggingface/tokenizers": "0.2.0",
53
56
  "@lezer/highlight": "^1.2.3",
54
57
  "@lezer/markdown": "^1.6.4",
55
- "@modelcontextprotocol/sdk": "^1.30.0",
56
- "@noble/curves": "^2.2.0",
57
- "@noble/hashes": "^2.2.0",
58
+ "@modelcontextprotocol/sdk": "1.30.0",
59
+ "@noble/curves": "2.2.0",
60
+ "@noble/hashes": "2.2.0",
58
61
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
59
62
  "fake-indexeddb": "^6.2.5",
60
63
  "katex": "^0.17.0",
61
- "lib0": "^0.2.117",
64
+ "lib0": "0.2.117",
62
65
  "markdown-it": "15.0.2",
63
66
  "mermaid": "^11.16.1",
64
67
  "mustache": "4.2.0",
65
68
  "playwright-core": "1.59.1",
66
- "y-protocols": "^1.0.7",
69
+ "y-protocols": "1.0.7",
67
70
  "yaml": "^2.9.0",
68
- "yjs": "^13.6.31",
71
+ "yjs": "13.6.31",
69
72
  "zod": "^4.3.6"
70
73
  },
71
74
  devDependencies: {
@@ -3926,7 +3929,7 @@ function indexFile(dir) {
3926
3929
  function vectorsFile(dir) {
3927
3930
  return join(dir, VECTORS_FILE_NAME);
3928
3931
  }
3929
- function request(req) {
3932
+ function request$1(req) {
3930
3933
  return new Promise((resolve, reject) => {
3931
3934
  req.onsuccess = () => resolve(req.result);
3932
3935
  req.onerror = () => reject(req.error);
@@ -3941,7 +3944,7 @@ function done(tx) {
3941
3944
  }
3942
3945
  /** Open the cache database the sync engine already created; never creates or upgrades it. */
3943
3946
  async function openCacheDb() {
3944
- const db = await request(indexedDB.open(CACHE_DB, CACHE_DB_VERSION));
3947
+ const db = await request$1(indexedDB.open(CACHE_DB, CACHE_DB_VERSION));
3945
3948
  for (const store of CACHE_STORES) if (!db.objectStoreNames.contains(store)) {
3946
3949
  db.close();
3947
3950
  throw new Error(`Local Cache database has no "${store}" store; open the graph cache before restoring it.`);
@@ -3979,7 +3982,7 @@ async function captureLocalCache(graphId) {
3979
3982
  try {
3980
3983
  const tx = db.transaction([...CACHE_STORES], "readonly");
3981
3984
  const rows = {};
3982
- for (const store of CACHE_STORES) rows[store] = (await request(tx.objectStore(store).getAll())).filter((row) => row.graphId === graphId).map(withStandaloneBuffers);
3985
+ for (const store of CACHE_STORES) rows[store] = (await request$1(tx.objectStore(store).getAll())).filter((row) => row.graphId === graphId).map(withStandaloneBuffers);
3983
3986
  await done(tx);
3984
3987
  return {
3985
3988
  version: CACHE_DB_VERSION,
@@ -9003,6 +9006,32 @@ function assetHashFromName(fileName) {
9003
9006
  ext: (match[3] ?? "").toLowerCase()
9004
9007
  };
9005
9008
  }
9009
+ /**
9010
+ * Whether a decoded asset name is one file inside `assets/`, and so safe to join onto a folder.
9011
+ *
9012
+ * An asset reference comes from a document, and a document can come from a collaborator, an
9013
+ * import or an agent, so the name it decodes to is untrusted: `../assets/..%2Fpages%2FSecret.md`
9014
+ * decodes to a path that leaves `assets/`. Every place that turns a reference into a file path
9015
+ * (the asset stores, the publisher's bundle) checks this first, before any directory adapter
9016
+ * or site writer sees the name.
9017
+ */
9018
+ function isSafeAssetName(name) {
9019
+ if (!isSingleFileName(name)) return false;
9020
+ for (const char of name) {
9021
+ const code = char.codePointAt(0) ?? 0;
9022
+ if (code < 32 || code === 127) return false;
9023
+ }
9024
+ return true;
9025
+ }
9026
+ /**
9027
+ * Whether `name` is one entry of a directory rather than a path: not empty, not `.` or `..`, and
9028
+ * free of separators and NUL. What a directory adapter requires of every name it is handed, which
9029
+ * is looser than {@link isSafeAssetName}: a file already on disk may carry other odd characters.
9030
+ */
9031
+ function isSingleFileName(name) {
9032
+ if (name === "" || name === "." || name === "..") return false;
9033
+ return !name.includes("/") && !name.includes("\\") && !name.includes("\0");
9034
+ }
9006
9035
  //#endregion
9007
9036
  //#region ../client/src/lib/storage/fs/asset-store.ts
9008
9037
  /**
@@ -9103,11 +9132,22 @@ var MIME_TYPES = {
9103
9132
  function mimeTypeForExt(ext) {
9104
9133
  return MIME_TYPES[ext.replace(/^\./, "").toLowerCase()] ?? "";
9105
9134
  }
9106
- /** The on-disk name referenced by a doc-relative asset ref, or `null` if `ref` is not an asset reference. */
9135
+ /**
9136
+ * The on-disk name referenced by a doc-relative asset ref, or `null` if `ref` is not an asset
9137
+ * reference. A reference whose name decodes to anything but one file inside `assets/`, or does not
9138
+ * decode at all, is not one: it can never name a file on disk (see `isSafeAssetName`).
9139
+ */
9107
9140
  function assetNameFromRef(ref) {
9108
9141
  const clean = ref.split(/[?#]/)[0];
9109
9142
  const match = ASSET_REF$1.exec(clean);
9110
- return match ? decodeURIComponent(match[1]) : null;
9143
+ if (!match) return null;
9144
+ let name;
9145
+ try {
9146
+ name = decodeURIComponent(match[1]);
9147
+ } catch {
9148
+ return null;
9149
+ }
9150
+ return isSafeAssetName(name) ? name : null;
9111
9151
  }
9112
9152
  /**
9113
9153
  * The markdown to embed a saved asset: an inline image, or a plain link that downloads on click.
@@ -11854,6 +11894,16 @@ function isTransient(error) {
11854
11894
  function kebabStem(name) {
11855
11895
  return name.replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "asset";
11856
11896
  }
11897
+ /**
11898
+ * The MIME type a downloaded asset is given: its extension's, from the same short list the
11899
+ * Filesystem Backend uses, or `application/octet-stream`. Never the type recorded in the asset's
11900
+ * metadata, which is whatever the uploader's client sent: a collaborator could type an asset
11901
+ * `text/html`, and a blob opened in its own tab would then be a same-origin HTML document. The
11902
+ * extension is the reference's, the one the viewers are chosen by.
11903
+ */
11904
+ function assetTypeFor(ref) {
11905
+ return mimeTypeForExt(extOf(ref)) || "application/octet-stream";
11906
+ }
11857
11907
  function extOf(name) {
11858
11908
  const m = /\.([^.]+)$/.exec(name);
11859
11909
  return m ? m[1].toLowerCase() : "bin";
@@ -11947,7 +11997,7 @@ function createServerAssetStore(deps) {
11947
11997
  return {
11948
11998
  bytes: joined,
11949
11999
  name: metadata.name,
11950
- type: metadata.type
12000
+ type: assetTypeFor(ref)
11951
12001
  };
11952
12002
  }
11953
12003
  return {
@@ -13815,15 +13865,22 @@ function wikilinkRule(md, resolve) {
13815
13865
  */
13816
13866
  /** A document-relative asset reference: any number of `../`, `assets/`, the name. */
13817
13867
  var ASSET_REF = /^(?:\.\.\/)*assets\/(.+)$/;
13868
+ /**
13869
+ * The asset a reference names, decoded, or null when it names none. A name that decodes to
13870
+ * anything but one file inside `assets/` is not an asset: it would become a bundle path, and a site
13871
+ * folder, outside `assets/` (see `isSafeAssetName`). The link is then left as the author wrote it.
13872
+ */
13818
13873
  function assetNameOf(ref) {
13819
13874
  const match = ASSET_REF.exec(ref);
13820
13875
  if (!match) return null;
13821
13876
  const raw = match[1].split(/[?#]/)[0];
13877
+ let name;
13822
13878
  try {
13823
- return decodeURIComponent(raw);
13879
+ name = decodeURIComponent(raw);
13824
13880
  } catch {
13825
- return raw;
13881
+ name = raw;
13826
13882
  }
13883
+ return isSafeAssetName(name) ? name : null;
13827
13884
  }
13828
13885
  function escapeHtml$3(text) {
13829
13886
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -14031,7 +14088,7 @@ function linkTargetPattern(excluded = "") {
14031
14088
  * A regex may not repeat a group name, so compose this **once** per pattern. Wrap it in a group of
14032
14089
  * your own where you need the whole link as one capture.
14033
14090
  */
14034
- var MARKDOWN_LINK = `(?<bang>!?)\\[(?<label>[^\\]]*)\\]\\((?<target>${linkTargetPattern()})\\)`;
14091
+ var MARKDOWN_LINK = `(?<bang>!?)\\[(?<label>(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*)\\]\\((?<target>${linkTargetPattern()})\\)`;
14035
14092
  /** A match's groups, typed. Every {@link MARKDOWN_LINK} match has all three. */
14036
14093
  function linkGroups(match) {
14037
14094
  return match.groups;
@@ -14133,6 +14190,24 @@ function contentOf(block) {
14133
14190
  return first.replace(/^-\s+/, "").trim();
14134
14191
  }
14135
14192
  var WHOLE_LINK = new RegExp(`^${MARKDOWN_LINK}$`);
14193
+ /** The schemes an outline link may carry onto a published page; a target with no scheme is a path on the site. */
14194
+ var NAV_LINK_SCHEMES = new Set([
14195
+ "http",
14196
+ "https",
14197
+ "mailto"
14198
+ ]);
14199
+ /**
14200
+ * An outline link's target, or null when a published page must not carry it. Links in page bodies
14201
+ * go through markdown-it's validateLink; the outline is parsed here, so it gets its own rule. The
14202
+ * scheme is read after removing the control characters and spaces a browser would ignore, so
14203
+ * '\u0001javascript:' is read as javascript.
14204
+ */
14205
+ function navLinkTarget(target) {
14206
+ const visible = [...target].filter((c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) !== 127).join("");
14207
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(visible);
14208
+ if (!scheme) return target;
14209
+ return NAV_LINK_SCHEMES.has(scheme[1].toLowerCase()) ? target : null;
14210
+ }
14136
14211
  function buildNav(outline, resolver) {
14137
14212
  const issues = [];
14138
14213
  function convert(blocks) {
@@ -14166,10 +14241,24 @@ function buildNav(outline, resolver) {
14166
14241
  const link = WHOLE_LINK.exec(content);
14167
14242
  if (link && !linkGroups(link).bang) {
14168
14243
  const { label, target } = linkGroups(link);
14244
+ const href = navLinkTarget(target);
14245
+ if (href === null) {
14246
+ issues.push({
14247
+ level: "warning",
14248
+ code: "nav-link-unsafe",
14249
+ message: `The navigation links "${label}" to a target a published page does not carry (only http, https, mailto and site paths); the entry is shown without its link.`
14250
+ });
14251
+ out.push({
14252
+ label,
14253
+ labelHtml: escapeHtml$1(label),
14254
+ children
14255
+ });
14256
+ continue;
14257
+ }
14169
14258
  out.push({
14170
14259
  label,
14171
14260
  labelHtml: escapeHtml$1(label),
14172
- href: target,
14261
+ href,
14173
14262
  external: true,
14174
14263
  children
14175
14264
  });
@@ -14781,7 +14870,7 @@ async function publishPublication(source, publication, env, options = {}) {
14781
14870
  let assetsDone = 0;
14782
14871
  for (const [name, from] of wanted) {
14783
14872
  progress("assets", assetsDone++, wanted.size);
14784
- const asset = await source.readAsset(`../assets/${name}`);
14873
+ const asset = await source.readAsset(`../assets/${encodeURIComponent(name)}`);
14785
14874
  if (!asset) {
14786
14875
  report.assets.missing.push({
14787
14876
  name,
@@ -14931,6 +15020,8 @@ function themeFilesOfBundled(theme) {
14931
15020
  files
14932
15021
  };
14933
15022
  }
15023
+ /** Far more files than any theme uses, and a bound on the fetches one publish can trigger. */
15024
+ var MAX_URL_THEME_FILES = 500;
14934
15025
  /** Fetch a theme from its manifest URL; every file the manifest lists, relative to it. */
14935
15026
  async function fetchTheme(url, fetchText) {
14936
15027
  let json;
@@ -14942,10 +15033,12 @@ async function fetchTheme(url, fetchText) {
14942
15033
  const { manifest, errors } = parseThemeManifest(json);
14943
15034
  if (!manifest) throw new Error(`The theme at ${url} cannot be used: ${errors.join(" ")}`);
14944
15035
  if (manifest.files.length === 0) throw new Error(`The theme at ${url} lists no files in its manifest, so nothing can be fetched.`);
15036
+ if (manifest.files.length > MAX_URL_THEME_FILES) throw new Error(`The theme at ${url} lists ${manifest.files.length} files; a theme may list at most ${MAX_URL_THEME_FILES}.`);
14945
15037
  const base = new URL(url);
14946
15038
  const files = /* @__PURE__ */ new Map();
14947
15039
  for (const path of manifest.files) {
14948
15040
  const fileUrl = new URL(path, base).toString();
15041
+ if (!isThemeFilePath(path) || new URL(fileUrl).origin !== base.origin) throw new Error(`The theme at ${url} lists "${path}", which is not a theme file beside its manifest (layouts/, partials/ or assets/).`);
14949
15042
  try {
14950
15043
  files.set(path, await fetchText(fileUrl));
14951
15044
  } catch (error) {
@@ -15277,6 +15370,150 @@ async function openDiagramRenderer(env) {
15277
15370
  };
15278
15371
  }
15279
15372
  //#endregion
15373
+ //#region src/public-fetch.ts
15374
+ /**
15375
+ * Fetching text from the public internet, and nowhere else: the only network reads a tool makes
15376
+ * with an address it did not choose itself (a theme published at a URL, and every file its
15377
+ * manifest lists). The URL comes from a document or a tool argument, so a collaborator or a
15378
+ * prompt-injected agent could otherwise point this process at a service on the local machine or
15379
+ * the network it sits on, and read the answer back through `read_theme_file`.
15380
+ *
15381
+ * The rules: https only, no credentials in the URL, every address the host resolves to must be
15382
+ * public, redirects are followed by hand and each hop checked the same way, and a response is cut
15383
+ * off by a deadline and a size cap. The address check runs inside the socket's own DNS lookup, so
15384
+ * the address connected to is the address checked; resolving first and connecting afterwards
15385
+ * would let a second resolution answer differently.
15386
+ */
15387
+ /** Ranges no theme is served from: loopback, private, link-local, shared, reserved, documentation, multicast. */
15388
+ var blocked = new BlockList();
15389
+ for (const [network, prefix] of [
15390
+ ["0.0.0.0", 8],
15391
+ ["10.0.0.0", 8],
15392
+ ["100.64.0.0", 10],
15393
+ ["127.0.0.0", 8],
15394
+ ["169.254.0.0", 16],
15395
+ ["172.16.0.0", 12],
15396
+ ["192.0.0.0", 24],
15397
+ ["192.0.2.0", 24],
15398
+ ["192.168.0.0", 16],
15399
+ ["198.18.0.0", 15],
15400
+ ["198.51.100.0", 24],
15401
+ ["203.0.113.0", 24],
15402
+ ["224.0.0.0", 4],
15403
+ ["240.0.0.0", 4]
15404
+ ]) blocked.addSubnet(network, prefix, "ipv4");
15405
+ for (const [network, prefix] of [
15406
+ ["::", 128],
15407
+ ["::1", 128],
15408
+ ["64:ff9b::", 96],
15409
+ ["100::", 64],
15410
+ ["2001:db8::", 32],
15411
+ ["fc00::", 7],
15412
+ ["fe80::", 10],
15413
+ ["ff00::", 8]
15414
+ ]) blocked.addSubnet(network, prefix, "ipv6");
15415
+ /** Whether an IP address is one on the public internet. Anything that is not an address is not. */
15416
+ function isPublicAddress(address) {
15417
+ const family = isIP(address);
15418
+ if (family === 4) return !blocked.check(address, "ipv4");
15419
+ if (family !== 6) return false;
15420
+ const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
15421
+ if (dotted) return isPublicAddress(dotted[1]);
15422
+ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address);
15423
+ if (hex) {
15424
+ const [high, low] = [parseInt(hex[1], 16), parseInt(hex[2], 16)];
15425
+ return isPublicAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
15426
+ }
15427
+ return !blocked.check(address, "ipv6");
15428
+ }
15429
+ var DEFAULT_TIMEOUT_MS = 15e3;
15430
+ /** Far above any real theme file, and a bound on what one response can hold in memory. */
15431
+ var DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
15432
+ var DEFAULT_MAX_REDIRECTS = 3;
15433
+ var NotPublicError = class extends Error {
15434
+ code = "ENOTPUBLIC";
15435
+ };
15436
+ /** A lookup that answers only when every address the name resolves to is public. */
15437
+ function publicOnly(lookup) {
15438
+ return (hostname, options, callback) => {
15439
+ lookup(hostname, { all: true }, (error, addresses) => {
15440
+ if (error) return callback(error);
15441
+ const refused = addresses.find((entry) => !isPublicAddress(entry.address));
15442
+ if (refused || addresses.length === 0) return callback(new NotPublicError(`${hostname} resolves to ${refused?.address ?? "no address"}, which is not a public address.`));
15443
+ if (options.all) callback(null, addresses);
15444
+ else callback(null, addresses[0].address, addresses[0].family);
15445
+ });
15446
+ };
15447
+ }
15448
+ function checkUrl(url) {
15449
+ if (url.protocol !== "https:") throw new Error(`Only https URLs can be fetched, not ${url.protocol}//${url.host}.`);
15450
+ if (url.username || url.password) throw new Error("A URL with credentials in it cannot be fetched.");
15451
+ }
15452
+ function fetchOnce(url, deps, deadline) {
15453
+ return new Promise((resolve, reject) => {
15454
+ const remaining = deadline - Date.now();
15455
+ if (remaining <= 0) return reject(/* @__PURE__ */ new Error(`Fetching ${url.href} took longer than ${deps.timeoutMs / 1e3} seconds.`));
15456
+ const req = deps.request(url, {
15457
+ method: "GET",
15458
+ lookup: publicOnly(deps.lookup),
15459
+ headers: {
15460
+ accept: "*/*",
15461
+ "user-agent": "etherpk-mcp"
15462
+ }
15463
+ }, (res) => {
15464
+ const status = res.statusCode ?? 0;
15465
+ if (status >= 300 && status < 400 && res.headers.location) {
15466
+ res.resume();
15467
+ return resolve({ redirect: new URL(res.headers.location, url) });
15468
+ }
15469
+ if (status < 200 || status >= 300) {
15470
+ res.resume();
15471
+ return reject(new Error(`${status} ${res.statusMessage ?? ""}`.trim()));
15472
+ }
15473
+ const chunks = [];
15474
+ let size = 0;
15475
+ res.on("data", (chunk) => {
15476
+ size += chunk.byteLength;
15477
+ if (size > deps.maxBytes) {
15478
+ req.destroy(/* @__PURE__ */ new Error(`${url.href} is larger than ${deps.maxBytes} bytes.`));
15479
+ return;
15480
+ }
15481
+ chunks.push(chunk);
15482
+ });
15483
+ res.on("end", () => {
15484
+ clearTimeout(timer);
15485
+ if (size <= deps.maxBytes) resolve({ text: Buffer.concat(chunks).toString("utf8") });
15486
+ });
15487
+ res.on("error", reject);
15488
+ });
15489
+ const timer = setTimeout(() => req.destroy(/* @__PURE__ */ new Error(`Fetching ${url.href} took longer than ${deps.timeoutMs / 1e3} seconds.`)), remaining);
15490
+ req.on("error", (error) => {
15491
+ clearTimeout(timer);
15492
+ reject(error);
15493
+ });
15494
+ req.end();
15495
+ });
15496
+ }
15497
+ /** The text at a public https URL, under the rules at the top of this module. */
15498
+ async function fetchPublicText(href, options = {}) {
15499
+ const deps = {
15500
+ lookup: options.lookup ?? lookup,
15501
+ request: options.request ?? request,
15502
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
15503
+ maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
15504
+ maxRedirects: options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
15505
+ };
15506
+ const deadline = Date.now() + deps.timeoutMs;
15507
+ let url = new URL(href);
15508
+ for (let hops = 0;; hops += 1) {
15509
+ checkUrl(url);
15510
+ const hop = await fetchOnce(url, deps, deadline);
15511
+ if ("text" in hop) return hop.text;
15512
+ if (hops >= deps.maxRedirects) throw new Error(`${href} redirected more than ${deps.maxRedirects} times.`);
15513
+ url = hop.redirect;
15514
+ }
15515
+ }
15516
+ //#endregion
15280
15517
  //#region src/publish-environment.ts
15281
15518
  /**
15282
15519
  * The publisher's environment in Node (ADR 0082, ADR 0084): themes resolved the way the Client
@@ -15286,11 +15523,8 @@ async function openDiagramRenderer(env) {
15286
15523
  * counterpart of `host/browser-environment.ts`; the core is shared.
15287
15524
  */
15288
15525
  var require = createRequire(import.meta.url);
15289
- async function fetchText$1(url) {
15290
- const response = await fetch(url);
15291
- if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
15292
- return response.text();
15293
- }
15526
+ /** A url theme's files, from public https hosts only (see public-fetch.ts). */
15527
+ var fetchText$1 = (url) => fetchPublicText(url);
15294
15528
  var katexAssetsPromise = null;
15295
15529
  /** `katex.min.css` plus its woff2 fonts under `fonts/`, as the stylesheet references them. */
15296
15530
  async function katexAssets() {
@@ -15445,6 +15679,56 @@ function withPublishFolder(folders, graphKey, publicationId, folder) {
15445
15679
  } };
15446
15680
  }
15447
15681
  //#endregion
15682
+ //#region src/local-folders.ts
15683
+ /**
15684
+ * Where the tools may write on this machine. A tool argument is chosen by the agent, and the agent
15685
+ * can be steered by what it reads in the notes, so a folder it names is only ever a place under
15686
+ * the graph's downloads directory (ADR 0086 keeps even that choice from `publish`). A name that
15687
+ * resolves outside it, directly or through a symbolic link, is refused.
15688
+ */
15689
+ /** A folder the agent named that is not under the base; the tools report it as `invalid_argument`. */
15690
+ var FolderRefused = class extends Error {};
15691
+ /** Whether `child` is `parent` or a path beneath it; both already resolved. */
15692
+ function within(parent, child) {
15693
+ return child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
15694
+ }
15695
+ /** The nearest part of `path` that exists on disk: the path itself, or its closest existing parent. */
15696
+ async function existingPart(path) {
15697
+ let at = path;
15698
+ for (;;) try {
15699
+ await lstat(at);
15700
+ return at;
15701
+ } catch {
15702
+ const up = dirname(at);
15703
+ if (up === at) return at;
15704
+ at = up;
15705
+ }
15706
+ }
15707
+ /**
15708
+ * The folder the agent asked for under `base`, or `fallback` (a path relative to `base`) when it
15709
+ * named none. A relative name is taken relative to `base`; an absolute one must already be under
15710
+ * it. Checked twice: as written, and after resolving every symbolic link in the part that exists.
15711
+ */
15712
+ async function folderUnder(base, requested, fallback) {
15713
+ const root = resolve(base);
15714
+ const wanted = requested?.trim() ? requested.trim() : fallback;
15715
+ const target = resolve(root, wanted);
15716
+ if (!within(root, target)) throw new FolderRefused(`"${wanted}" is outside ${root}. Name a folder under it, or leave it out for the default.`);
15717
+ await mkdir(root, { recursive: true });
15718
+ if (!within(await realpath(root), await realpath(await existingPart(target)))) throw new FolderRefused(`"${wanted}" leads outside ${root} through a symbolic link.`);
15719
+ return target;
15720
+ }
15721
+ /**
15722
+ * Whether a preview page may load `url`: its own files and inline data, nothing else. A theme's
15723
+ * script runs when the preview is photographed, and a theme can come from a collaborator or a URL,
15724
+ * so the page is kept off the network and away from the rest of the disk.
15725
+ */
15726
+ function previewRequestAllowed(url, folder) {
15727
+ if (url.startsWith("data:") || url.startsWith("blob:") || url === "about:blank") return true;
15728
+ const own = pathToFileURL(folder.endsWith(sep) ? folder : folder + sep).href;
15729
+ return url.startsWith(own);
15730
+ }
15731
+ //#endregion
15448
15732
  //#region src/headless-assets.ts
15449
15733
  /**
15450
15734
  * What the asset tools need from a graph's [[Asset]]s, behind one seam for both backends
@@ -15455,9 +15739,46 @@ function withPublishFolder(folders, graphKey, publicationId, folder) {
15455
15739
  * reference names an asset (`identify`), which is what the index is asked about when the tools
15456
15740
  * decide whether an asset is reachable from a document the agent can read.
15457
15741
  */
15458
- /** A local file as `upload_asset` reads it. */
15459
- async function readLocalFile(path) {
15460
- const buffer = await readFile(path);
15742
+ /**
15743
+ * The largest file `upload_asset` reads. The Sync Server applies its own, usually smaller, limit;
15744
+ * this one bounds what a single call can pull into memory whatever the backend.
15745
+ */
15746
+ var MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
15747
+ async function realOrResolved(path) {
15748
+ try {
15749
+ return await realpath(path);
15750
+ } catch {
15751
+ return resolve(path);
15752
+ }
15753
+ }
15754
+ /**
15755
+ * Refuse a file an agent should not be able to put into a graph. The path is a tool argument, and
15756
+ * an agent can be steered by what it reads, so an upload is a way to copy a local file somewhere
15757
+ * collaborators can read it. Refused: the Headless Client's cache (the graph's decrypted
15758
+ * contents) and config (sign-in tokens), and anything hidden (a dot-file, or a file inside a
15759
+ * dot-folder such as `.ssh` or `.aws`), which is where credentials and settings live. `real` has
15760
+ * every link resolved, so a link is judged by the file it leads to.
15761
+ */
15762
+ async function refuseProtected(path, real, rules) {
15763
+ if (within(await realOrResolved(rules.downloadsDir), real)) return;
15764
+ if (within(await realOrResolved(cacheRoot(rules.env)), real)) throw new Error(`${path} is in the Headless Client's cache, which holds the graph's decrypted contents.`);
15765
+ const configFile = await realOrResolved(defaultConfigPath(rules.env));
15766
+ const configDir = await realOrResolved(dirname(defaultConfigPath({
15767
+ ...rules.env,
15768
+ ETHERPK_MCP_CONFIG: void 0
15769
+ })));
15770
+ if (real === configFile || within(configDir, real)) throw new Error(`${path} is the Headless Client's config, which holds its sign-in tokens.`);
15771
+ const hidden = real.split(sep).find((part) => part.startsWith("."));
15772
+ if (hidden) throw new Error(`${path} is hidden (${hidden}), where credentials and settings are kept. Copy it to an ordinary folder to upload it.`);
15773
+ }
15774
+ /** A local file as `upload_asset` reads it: an ordinary file, not refused above, within the size limit. */
15775
+ async function readLocalFile(path, rules) {
15776
+ const real = await realpath(path);
15777
+ await refuseProtected(path, real, rules);
15778
+ const info = await stat(real);
15779
+ if (!info.isFile()) throw new Error(`${path} is not a file.`);
15780
+ if (info.size > 104857600) throw new Error(`${path} is larger than ${MAX_UPLOAD_BYTES / (1024 * 1024)} MiB.`);
15781
+ const buffer = await readFile(real);
15461
15782
  const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
15462
15783
  bytes.set(buffer);
15463
15784
  return {
@@ -15465,24 +15786,54 @@ async function readLocalFile(path) {
15465
15786
  bytes
15466
15787
  };
15467
15788
  }
15468
- /**
15469
- * Write an asset's bytes under `directory` as `name`, never over a different file of the same
15470
- * name: a second asset that happens to share a display name lands beside it with a suffix, so
15471
- * an agent reading two `diagram.png`s from two pages gets both.
15789
+ /** Names Windows reserves for devices, whatever the extension. */
15790
+ var RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
15791
+ var MAX_NAME_LENGTH = 120;
15792
+ /**
15793
+ * A file name for an asset's stored name. The stored name is metadata any collaborator can set,
15794
+ * so it is reduced to one plain file: the last path segment, letters, digits, marks, spaces and
15795
+ * `. _ - ( )` kept and everything else replaced, no leading dot (so never hidden, never `..`),
15796
+ * no trailing dot or space (Windows drops them), not a Windows device name, and short.
15797
+ */
15798
+ function downloadName(name) {
15799
+ let safe = (name.split(/[\\/]/).pop() ?? "").replace(/[^\p{L}\p{M}\p{N} ._()-]/gu, "_").replace(/^[.\s]+/, "").replace(/[.\s]+$/, "");
15800
+ if (safe === "") return "asset";
15801
+ if (RESERVED.test(safe.split(".")[0])) safe = `_${safe}`;
15802
+ if (Array.from(safe).length <= MAX_NAME_LENGTH) return safe;
15803
+ const dot = safe.lastIndexOf(".");
15804
+ const ext = dot > 0 && safe.length - dot <= 20 ? safe.slice(dot) : "";
15805
+ return Array.from(safe.slice(0, safe.length - ext.length)).slice(0, MAX_NAME_LENGTH - ext.length).join("") + ext;
15806
+ }
15807
+ /** Whether `path` is an ordinary file (not a link) holding exactly `bytes`. */
15808
+ async function holds(path, bytes) {
15809
+ const info = await lstat(path);
15810
+ if (!info.isFile() || info.size !== bytes.byteLength) return false;
15811
+ return (await readFile(path)).equals(bytes);
15812
+ }
15813
+ /**
15814
+ * Write an asset's bytes under `directory`, named by {@link downloadName}, and never over
15815
+ * anything already there: the write fails rather than replace a file or follow a link left in
15816
+ * its place, and the next candidate is tried. A second asset that shares a display name lands
15817
+ * beside the first with a suffix, so an agent reading two `diagram.png`s from two pages gets
15818
+ * both; the same bytes again answer the file already written.
15472
15819
  */
15473
15820
  async function writeDownload(directory, name, bytes) {
15474
15821
  await mkdir(directory, { recursive: true });
15475
- const dot = name.lastIndexOf(".");
15476
- const stem = dot > 0 ? name.slice(0, dot) : name;
15477
- const ext = dot > 0 ? name.slice(dot) : "";
15478
- let candidate = join(directory, name);
15479
- for (let n = 2; existsSync(candidate); n += 1) {
15480
- const existing = await readFile(candidate);
15481
- if (existing.byteLength === bytes.byteLength && existing.every((b, i) => b === bytes[i])) return candidate;
15482
- candidate = join(directory, `${stem}-${n}${ext}`);
15483
- }
15484
- await writeFile(candidate, bytes);
15485
- return candidate;
15822
+ const safe = downloadName(name);
15823
+ const dot = safe.lastIndexOf(".");
15824
+ const stem = dot > 0 ? safe.slice(0, dot) : safe;
15825
+ const ext = dot > 0 ? safe.slice(dot) : "";
15826
+ for (let n = 1; n <= 1e3; n += 1) {
15827
+ const candidate = join(directory, n === 1 ? safe : `${stem}-${n}${ext}`);
15828
+ try {
15829
+ await writeFile(candidate, bytes, { flag: "wx" });
15830
+ return candidate;
15831
+ } catch (error) {
15832
+ if (error.code !== "EEXIST") throw error;
15833
+ }
15834
+ if (await holds(candidate, bytes)) return candidate;
15835
+ }
15836
+ throw new Error(`${directory} already holds a thousand files named like ${safe}.`);
15486
15837
  }
15487
15838
  /** UTF-16 units of document text returned before `truncated` is set. */
15488
15839
  var READ_TEXT_CAP = 2e5;
@@ -16029,7 +16380,10 @@ async function uploadAsset(graph, args) {
16029
16380
  if (!args.path || args.path.trim() === "") throw new ToolError("invalid_argument", "path must name a file on this machine.");
16030
16381
  let file;
16031
16382
  try {
16032
- file = await readLocalFile(args.path.trim());
16383
+ file = await readLocalFile(args.path.trim(), {
16384
+ env: process.env,
16385
+ downloadsDir: assets.downloadsDir
16386
+ });
16033
16387
  } catch (error) {
16034
16388
  throw new ToolError("invalid_argument", `Cannot read "${args.path}": ${error instanceof Error ? error.message : String(error)}`);
16035
16389
  }
@@ -16074,8 +16428,15 @@ async function readAsset(graph, args) {
16074
16428
  if (!documents) throw new ToolError("asset_not_found", `No document you can read references "${displayNameOf(ref)}", so it is not available here.`);
16075
16429
  const asset = await assets.store.readBytes(ref);
16076
16430
  if (!asset) throw new ToolError("asset_not_found", `The graph does not hold "${displayNameOf(ref)}" (the reference may be broken).`);
16431
+ let directory;
16432
+ try {
16433
+ directory = await folderUnder(assets.downloadsDir, args.out_dir, ".");
16434
+ } catch (error) {
16435
+ if (error instanceof FolderRefused) throw new ToolError("invalid_argument", error.message);
16436
+ throw error;
16437
+ }
16077
16438
  return {
16078
- path: await writeDownload(args.out_dir?.trim() || assets.downloadsDir, asset.name || displayNameOf(ref), asset.bytes),
16439
+ path: await writeDownload(directory, asset.name || displayNameOf(ref), asset.bytes),
16079
16440
  name: asset.name || displayNameOf(ref),
16080
16441
  type: asset.type,
16081
16442
  bytes: asset.bytes.byteLength,
@@ -16626,15 +16987,25 @@ function hostOf(host) {
16626
16987
  cmd: host?.cmd ?? "etherpk-mcp"
16627
16988
  };
16628
16989
  }
16990
+ /** The graph's downloads directory: every folder these tools write to, or read a theme back from, is under it. */
16991
+ function downloadsOf(graph) {
16992
+ return graph.assets?.downloadsDir ?? join(process.cwd(), "etherpk-downloads");
16993
+ }
16994
+ /** A folder under the downloads directory, or the tool's refusal. */
16995
+ async function toolFolder(graph, requested, fallback) {
16996
+ try {
16997
+ return await folderUnder(downloadsOf(graph), requested, fallback);
16998
+ } catch (error) {
16999
+ if (error instanceof FolderRefused) throw new ToolError("invalid_argument", error.message);
17000
+ throw error;
17001
+ }
17002
+ }
16629
17003
  async function settle(graph) {
16630
17004
  const result = await graph.settle();
16631
17005
  if (!result.settled) throw new ToolError("not_settled", result.message);
16632
17006
  }
16633
- async function fetchText(url) {
16634
- const response = await fetch(url);
16635
- if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
16636
- return response.text();
16637
- }
17007
+ /** A url theme's files, from public https hosts only (see public-fetch.ts). */
17008
+ var fetchText = (url) => fetchPublicText(url);
16638
17009
  /** The publications whose saved mapping names a theme: what makes it undeletable. */
16639
17010
  async function publicationsUsing(graph, themeId) {
16640
17011
  const { source } = await graph.publishing.readSource();
@@ -16731,12 +17102,11 @@ async function readTheme(graph, args) {
16731
17102
  await graph.store.refresh();
16732
17103
  const ref = args.ref.trim();
16733
17104
  const { source, files, theme } = await filesOf(graph, ref);
16734
- const folder = resolve(args.out_dir?.trim() || join(graph.assets?.downloadsDir ?? join(process.cwd(), "etherpk-downloads"), "themes", ref.replace(/[^A-Za-z0-9._-]/g, "_")));
17105
+ const folder = await toolFolder(graph, args.out_dir, join("themes", ref.replace(/[^A-Za-z0-9._-]/g, "_")));
17106
+ const site = nodeSiteFolder(folder);
16735
17107
  const written = [];
16736
17108
  for (const [path, text] of [...files.entries()].sort(([a], [b]) => a.localeCompare(b))) {
16737
- const full = join(folder, ...path.split("/"));
16738
- await mkdir(dirname(full), { recursive: true });
16739
- await writeFile(full, text);
17109
+ await site.writeFile(path, text);
16740
17110
  written.push({
16741
17111
  path,
16742
17112
  bytes: Buffer.byteLength(text)
@@ -16888,22 +17258,43 @@ async function deleteThemeFile(graph, args) {
16888
17258
  errors: validationOf(after)
16889
17259
  };
16890
17260
  }
16891
- /** Every allowed file under a directory, as the theme's whole file set. */
16892
- async function filesUnder(dir) {
16893
- const base = resolve(dir);
17261
+ /** Bounds on a theme folder: far beyond a real theme, and short of a whole disk. */
17262
+ var THEME_FOLDER_LIMITS = {
17263
+ files: 500,
17264
+ depth: 6,
17265
+ fileBytes: 1024 * 1024,
17266
+ totalBytes: 8 * 1024 * 1024
17267
+ };
17268
+ /**
17269
+ * Every allowed file under a directory, as the theme's whole file set. Symbolic links are not
17270
+ * followed, so a folder cannot pull in a file from elsewhere on disk, and the walk stops at the
17271
+ * limits above rather than reading whatever tree it was pointed at.
17272
+ */
17273
+ async function filesUnder(base) {
16894
17274
  const files = /* @__PURE__ */ new Map();
16895
- const walk = async (at) => {
17275
+ let seen = 0;
17276
+ let total = 0;
17277
+ const walk = async (at, depth) => {
17278
+ if (depth > THEME_FOLDER_LIMITS.depth) return;
16896
17279
  for (const entry of await readdir(at, { withFileTypes: true })) {
17280
+ if (entry.isSymbolicLink()) continue;
16897
17281
  const full = join(at, entry.name);
16898
17282
  if (entry.isDirectory()) {
16899
- if (entry.name !== ".git" && entry.name !== "node_modules") await walk(full);
17283
+ if (entry.name !== ".git" && entry.name !== "node_modules") await walk(full, depth + 1);
16900
17284
  continue;
16901
17285
  }
17286
+ if (!entry.isFile()) continue;
17287
+ if (++seen > THEME_FOLDER_LIMITS.files) throw new Error(`it holds more than ${THEME_FOLDER_LIMITS.files} files`);
16902
17288
  const path = relative(base, full).split(sep).join("/");
16903
- if (isThemeFilePath(path)) files.set(path, await readFile(full, "utf8"));
17289
+ if (!isThemeFilePath(path)) continue;
17290
+ const { size } = await lstat(full);
17291
+ if (size > THEME_FOLDER_LIMITS.fileBytes) throw new Error(`${path} is larger than ${THEME_FOLDER_LIMITS.fileBytes} bytes`);
17292
+ total += size;
17293
+ if (total > THEME_FOLDER_LIMITS.totalBytes) throw new Error(`its theme files come to more than ${THEME_FOLDER_LIMITS.totalBytes} bytes`);
17294
+ files.set(path, await readFile(full, "utf8"));
16904
17295
  }
16905
17296
  };
16906
- await walk(base);
17297
+ await walk(base, 0);
16907
17298
  return files;
16908
17299
  }
16909
17300
  /**
@@ -16914,9 +17305,11 @@ async function filesUnder(dir) {
16914
17305
  async function importThemeFolder(graph, args) {
16915
17306
  await graph.store.refresh();
16916
17307
  const theme = await requireEditable(graph, args.id.trim());
17308
+ if (!args.dir?.trim()) throw new ToolError("invalid_argument", "dir must name the theme folder, as read_theme returned it.");
17309
+ const dir = await toolFolder(graph, args.dir, "");
16917
17310
  let files;
16918
17311
  try {
16919
- files = await filesUnder(args.dir);
17312
+ files = await filesUnder(dir);
16920
17313
  } catch (error) {
16921
17314
  throw new ToolError("invalid_argument", `Cannot read "${args.dir}": ${error instanceof Error ? error.message : String(error)}`);
16922
17315
  }
@@ -16953,8 +17346,8 @@ async function deleteTheme(graph, args) {
16953
17346
  /**
16954
17347
  * Render a theme to a folder on this machine and say where: the publication's real site when
16955
17348
  * one is named, the sample site the Theme editor previews over otherwise. A preview is scratch,
16956
- * not a [[Publish Folder]]: the default location is under the graph's downloads directory and is
16957
- * emptied first; a named `out_dir` is written into as it is. With `screenshots`, the browser
17349
+ * not a [[Publish Folder]]: it is always under the graph's downloads directory, the default folder
17350
+ * there is emptied first, and a named `out_dir` (also under it) is written into as it is. With `screenshots`, the browser
16958
17351
  * photographs the front page and the first other page at desktop and phone widths, so the agent
16959
17352
  * can look at what it changed.
16960
17353
  */
@@ -16987,7 +17380,7 @@ async function previewTheme(graph, args, host) {
16987
17380
  });
16988
17381
  const { bundle, report } = await publishPublication(source, publication, environment);
16989
17382
  const scratch = !args.out_dir?.trim();
16990
- const folder = resolve(args.out_dir?.trim() || join(graph.assets?.downloadsDir ?? join(process.cwd(), "etherpk-downloads"), "previews", themeRef.replace(/[^A-Za-z0-9._-]/g, "_")));
17383
+ const folder = await toolFolder(graph, args.out_dir, join("previews", themeRef.replace(/[^A-Za-z0-9._-]/g, "_")));
16991
17384
  if (scratch) await rm(folder, {
16992
17385
  recursive: true,
16993
17386
  force: true
@@ -17018,12 +17411,10 @@ async function previewTheme(graph, args, host) {
17018
17411
  await renderer?.dispose();
17019
17412
  }
17020
17413
  }
17414
+ /** The rendered site into the folder, through the writer `publish` uses: no path may leave the folder. */
17021
17415
  async function writeBundle(folder, bundle) {
17022
- for (const [path, content] of bundle) {
17023
- const full = join(folder, ...path.split("/"));
17024
- await mkdir(dirname(full), { recursive: true });
17025
- await writeFile(full, content);
17026
- }
17416
+ const site = nodeSiteFolder(folder);
17417
+ for (const [path, content] of bundle) await site.writeFile(path, content);
17027
17418
  }
17028
17419
  /** The front page and the first other page, desktop and phone, as PNGs beside the site. */
17029
17420
  async function photograph(env, folder, pages) {
@@ -17033,12 +17424,16 @@ async function photograph(env, folder, pages) {
17033
17424
  const shots = [];
17034
17425
  const targets = ["index.html", ...pages.filter((p) => p !== "index.html" && p !== "404.html").slice(0, 1)];
17035
17426
  for (const page of targets) for (const width of [1280, 390]) {
17036
- const context = await browser.newContext({ viewport: {
17037
- width,
17038
- height: width === 390 ? 844 : 800
17039
- } });
17427
+ const context = await browser.newContext({
17428
+ viewport: {
17429
+ width,
17430
+ height: width === 390 ? 844 : 800
17431
+ },
17432
+ offline: true
17433
+ });
17434
+ await context.route("**/*", (route) => previewRequestAllowed(route.request().url(), folder) ? route.continue() : route.abort());
17040
17435
  const tab = await context.newPage();
17041
- await tab.goto(`file://${join(folder, page)}`, { waitUntil: "load" });
17436
+ await tab.goto(pathToFileURL(join(folder, page)).href, { waitUntil: "load" });
17042
17437
  const out = join(folder, `preview-${page.replace(/\.html$/, "")}-${width}.png`);
17043
17438
  await tab.screenshot({
17044
17439
  path: out,
@@ -17118,7 +17513,7 @@ function createMcpServer(graph, info) {
17118
17513
  "Start with graph_info to see what you are connected to. read_documents reads several documents in one call; tasks lists tasks and set_task changes one (status, priority, due and scheduled dates) by document and line.",
17119
17514
  "Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it; its outline is the site navigation) and the public documents none takes; a document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings; publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. Diagrams need a browser the user installs once with \"diagrams setup\".",
17120
17515
  "Themes: a publication's look is a theme - Mustache templates, a stylesheet, a script and a manifest. list_themes shows the bundled ones (read-only) and the graph's own; read_theme writes a theme's files to a folder on this machine to read and edit; customise_publication_theme copies a publication's bundled theme into the graph and points the publication at the copy (create_theme copies any theme); write_theme_file, delete_theme_file and import_theme_folder change a graph theme; preview_theme renders a theme to a folder (with screenshots when a browser is set up) to check before publish. For a snippet such as an analytics script, an include slot (update_publication includes, e.g. head) filled by a page may be lighter than a theme copy.",
17121
- "Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document; read_asset writes an asset to a local file you can open; list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session).",
17516
+ "Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document; read_asset writes an asset to a local file you can open; list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session). read_asset, read_theme and preview_theme write under the graph's downloads directory and return the path; name a folder relative to it, never elsewhere.",
17122
17517
  "The user documentation is at https://docs.etherpk.com."
17123
17518
  ].join("\n") });
17124
17519
  server.registerTool("list_documents", {
@@ -17277,18 +17672,18 @@ function createMcpServer(graph, info) {
17277
17672
  }, async (args) => run(() => setFrontmatter(graph, args)));
17278
17673
  server.registerTool("upload_asset", {
17279
17674
  title: "Upload an asset",
17280
- description: "Add a file on this machine (an image, a PDF, any file) to the graph as an asset. Returns the reference and the markdown to paste into a document (an image tag for an image, a link otherwise). Bytes the graph already holds are not stored twice: the result then says reused: true and points at the existing asset. The file is stored as it is, without image optimisation. Refused with error \"asset_refused\" when the server declines it on a quota.",
17675
+ description: "Add a file on this machine (an image, a PDF, any file) to the graph as an asset. Returns the reference and the markdown to paste into a document (an image tag for an image, a link otherwise). Bytes the graph already holds are not stored twice: the result then says reused: true and points at the existing asset. The file is stored as it is, without image optimisation. Refused with error \"invalid_argument\" for a folder, a file over 100 MiB, a hidden file or one in a hidden folder (.ssh, .env), and the Headless Client's own config and cache; with error \"asset_refused\" when the server declines it on a quota.",
17281
17676
  inputSchema: {
17282
- path: z.string().min(1).describe("Absolute path of the file on this machine."),
17677
+ path: z.string().min(1).describe("Absolute path of an ordinary file on this machine."),
17283
17678
  name: z.string().min(1).optional().describe("The name to store it under; the file's own name by default.")
17284
17679
  }
17285
17680
  }, async (args) => run(() => uploadAsset(graph, args)));
17286
17681
  server.registerTool("read_asset", {
17287
17682
  title: "Read an asset",
17288
- description: "Write an asset's bytes to a file on this machine and return the path, so you can open or view it. Pass the reference as a document shows it (\"../assets/<name>\") or the name alone. Available only where a document you can read references the asset (or you uploaded it this session); otherwise error \"asset_not_found\".",
17683
+ description: "Write an asset's bytes to a file on this machine and return the path, so you can open or view it. Pass the reference as a document shows it (\"../assets/<name>\") or the name alone. Available only where a document you can read references the asset (or you uploaded it this session); otherwise error \"asset_not_found\". The file is written under the graph's downloads directory, named after the asset, and never over an existing file.",
17289
17684
  inputSchema: {
17290
17685
  ref: z.string().min(1),
17291
- out_dir: z.string().min(1).optional().describe("Directory to write into; the graph's downloads directory by default.")
17686
+ out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it; the downloads directory itself by default. A folder outside it is refused.")
17292
17687
  }
17293
17688
  }, async (args) => run(() => readAsset(graph, args)));
17294
17689
  server.registerTool("list_assets", {
@@ -17349,7 +17744,7 @@ function createMcpServer(graph, info) {
17349
17744
  description: "Write a theme's files (theme.json, layouts/, partials/, assets/) to a folder on this machine and return the path and file list, so you can read and edit them with your own tools. ref is a graph theme id, a bundled theme name or a url. Editing the folder changes nothing until write_theme_file or import_theme_folder brings it back; a bundled or url theme cannot be edited in place at all (create_theme copies it).",
17350
17745
  inputSchema: {
17351
17746
  ref: z.string().min(1),
17352
- out_dir: z.string().min(1).optional().describe("Directory to write into; the graph's downloads directory by default.")
17747
+ out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it; themes/<ref> there by default. A folder outside it is refused.")
17353
17748
  }
17354
17749
  }, async (args) => run(() => readTheme(graph, args)));
17355
17750
  server.registerTool("read_theme_file", {
@@ -17397,7 +17792,7 @@ function createMcpServer(graph, info) {
17397
17792
  }, async (args) => run(() => deleteThemeFile(graph, args)));
17398
17793
  server.registerTool("import_theme_folder", {
17399
17794
  title: "Import a theme folder",
17400
- description: "Replace a graph theme's files with a folder's contents - the way back after editing what read_theme wrote. Files the folder no longer has are removed from the theme; the folder needs a theme.json at its top. Validated afterwards.",
17795
+ description: "Replace a graph theme's files with a folder's contents - the way back after editing what read_theme wrote. Files the folder no longer has are removed from the theme; the folder needs a theme.json at its top. dir is the folder read_theme returned, or another under the graph's downloads directory; a folder outside it is refused, and symbolic links in it are skipped. Validated afterwards.",
17401
17796
  inputSchema: {
17402
17797
  id: z.string().min(1),
17403
17798
  dir: z.string().min(1)
@@ -17410,7 +17805,7 @@ function createMcpServer(graph, info) {
17410
17805
  }, async (args) => run(() => deleteTheme(graph, args)));
17411
17806
  server.registerTool("preview_theme", {
17412
17807
  title: "Preview a theme",
17413
- description: "Render a theme to a folder on this machine and return where: a publication's real pages when a publication is given (with its own theme, or the theme named), or a sample site covering every construct when only a theme is given. Open the HTML to check markup and styles. With screenshots: true and a browser set up (diagrams setup or ETHERPK_CHROMIUM), the front page and one content page are photographed at desktop and phone widths as PNGs you can view. A preview is scratch, not the publish folder.",
17808
+ description: "Render a theme to a folder on this machine and return where: a publication's real pages when a publication is given (with its own theme, or the theme named), or a sample site covering every construct when only a theme is given. Open the HTML to check markup and styles. With screenshots: true and a browser set up (diagrams setup or ETHERPK_CHROMIUM), the front page and one content page are photographed at desktop and phone widths as PNGs you can view. A preview is scratch, not the publish folder. The page loads nothing from the network while it is photographed. out_dir is a folder under the graph's downloads directory (previews/<name> there by default); a folder outside it is refused.",
17414
17809
  inputSchema: {
17415
17810
  theme: z.string().min(1).optional(),
17416
17811
  publication: z.string().min(1).optional(),
@@ -17460,9 +17855,18 @@ function standalone(bytes) {
17460
17855
  copy.set(bytes);
17461
17856
  return copy;
17462
17857
  }
17858
+ /**
17859
+ * `name` as one file of `folder`, or a refusal. The store passes names that came from documents
17860
+ * (an asset reference decodes to one), and `join` would follow a `..` or a separator out of the
17861
+ * graph; the browser's directory handles refuse such names, and this keeps the two adapters alike.
17862
+ */
17863
+ function fileIn(folder, name) {
17864
+ if (!isSingleFileName(name)) throw new Error(`"${name}" is not a file name.`);
17865
+ return join(folder, name);
17866
+ }
17463
17867
  function createNodeDirectoryAdapter(root) {
17464
17868
  const dir = resolve(root);
17465
- const path = (subdir, name) => join(dir, subdir, name);
17869
+ const path = (subdir, name) => fileIn(join(dir, subdir), name);
17466
17870
  /**
17467
17871
  * The file's mtime as integer milliseconds, as the browser's `File.lastModified` is. Node's
17468
17872
  * `mtimeMs` is a float built from seconds plus nanoseconds, and on some filesystems the
@@ -17543,7 +17947,7 @@ function createNodeDirectoryAdapter(root) {
17543
17947
  for (const subdir of SUBDIRS) await mkdir(join(dir, subdir), { recursive: true });
17544
17948
  },
17545
17949
  async readRootFile(name) {
17546
- const file = join(dir, name);
17950
+ const file = fileIn(dir, name);
17547
17951
  let text;
17548
17952
  try {
17549
17953
  text = await readFile(file, "utf8");
@@ -17557,7 +17961,7 @@ function createNodeDirectoryAdapter(root) {
17557
17961
  };
17558
17962
  },
17559
17963
  async writeRootFile(name, text) {
17560
- const file = join(dir, name);
17964
+ const file = fileIn(dir, name);
17561
17965
  await writeFile(file, text, "utf8");
17562
17966
  return {
17563
17967
  text,
@@ -17670,9 +18074,9 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
17670
18074
  ${CMD} diagrams status
17671
18075
  Which browser a publish would use, if any.
17672
18076
 
17673
- This machine can be signed in to several Sync Servers at once (a self-hosted one beside the
17674
- managed service, say); --sync-server says which one a command means, and can be left out
17675
- while only one is signed in. The config file is ${defaultConfigPath()} (override with
18077
+ This machine can be signed in to several Sync Servers at once; --sync-server says which one
18078
+ a command means, and can be left out while only one is signed in. The config file is
18079
+ ${defaultConfigPath()} (override with
17676
18080
  ETHERPK_MCP_CONFIG); cached graphs live under ~/.cache/etherpk/mcp (override with
17677
18081
  ETHERPK_MCP_CACHE_DIR).
17678
18082
  Docs: https://docs.etherpk.com/using-ai-agents-with-your-notes