@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/README.md +14 -8
- package/dist/main.js +492 -88
- package/dist/main.js.map +1 -1
- package/package.json +9 -9
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.
|
|
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": "
|
|
56
|
-
"@noble/curves": "
|
|
57
|
-
"@noble/hashes": "
|
|
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": "
|
|
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": "
|
|
69
|
+
"y-protocols": "1.0.7",
|
|
67
70
|
"yaml": "^2.9.0",
|
|
68
|
-
"yjs": "
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
13879
|
+
name = decodeURIComponent(raw);
|
|
13824
13880
|
} catch {
|
|
13825
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
@@ -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
|
|
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
|
-
|
|
15290
|
-
|
|
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
|
-
/**
|
|
15459
|
-
|
|
15460
|
-
|
|
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
|
-
|
|
15470
|
-
|
|
15471
|
-
|
|
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
|
|
15476
|
-
const
|
|
15477
|
-
const
|
|
15478
|
-
|
|
15479
|
-
for (let n =
|
|
15480
|
-
const
|
|
15481
|
-
|
|
15482
|
-
|
|
15483
|
-
|
|
15484
|
-
|
|
15485
|
-
|
|
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(
|
|
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
|
-
|
|
16634
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
16892
|
-
|
|
16893
|
-
|
|
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
|
-
|
|
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))
|
|
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(
|
|
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]]:
|
|
16957
|
-
* emptied first
|
|
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 =
|
|
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
|
-
|
|
17023
|
-
|
|
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({
|
|
17037
|
-
|
|
17038
|
-
|
|
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(
|
|
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
|
|
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("
|
|
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("
|
|
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 =
|
|
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 =
|
|
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
|
|
17674
|
-
|
|
17675
|
-
|
|
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
|