virlow-mcp 3.80.0 → 3.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +789 -144
- package/package.json +4 -4
package/dist/cli.js
CHANGED
|
@@ -202,6 +202,32 @@ var VirlowApi = class {
|
|
|
202
202
|
updateFolder(id, body) {
|
|
203
203
|
return this.request("PUT", `/api/folders/${id}`, body);
|
|
204
204
|
}
|
|
205
|
+
listFiles(noteId, opts = {}) {
|
|
206
|
+
const qs = opts.trashed ? "?trashed=true" : "";
|
|
207
|
+
return this.request("GET", `/api/notes/${encodeURIComponent(noteId)}/files${qs}`);
|
|
208
|
+
}
|
|
209
|
+
/** The full row, ciphertext included. Trashed files are served too. */
|
|
210
|
+
getFile(id) {
|
|
211
|
+
return this.request("GET", `/api/files/${encodeURIComponent(id)}`);
|
|
212
|
+
}
|
|
213
|
+
/** Metadata only: what an exposure check needs, without up to 50 MB of ciphertext. */
|
|
214
|
+
getFileMeta(id) {
|
|
215
|
+
return this.request("GET", `/api/files/${encodeURIComponent(id)}?metadata=true`);
|
|
216
|
+
}
|
|
217
|
+
uploadFile(noteId, body) {
|
|
218
|
+
return this.request("POST", `/api/notes/${encodeURIComponent(noteId)}/files`, body);
|
|
219
|
+
}
|
|
220
|
+
patchFile(id, body) {
|
|
221
|
+
return this.request("PATCH", `/api/files/${encodeURIComponent(id)}`, body);
|
|
222
|
+
}
|
|
223
|
+
/** Moves the file to the note's Recently deleted. There is deliberately no
|
|
224
|
+
* way to hard-delete from here: Claude's deletes can always be undone. */
|
|
225
|
+
deleteFile(id) {
|
|
226
|
+
return this.request("DELETE", `/api/files/${encodeURIComponent(id)}`);
|
|
227
|
+
}
|
|
228
|
+
restoreFile(id) {
|
|
229
|
+
return this.request("POST", `/api/files/${encodeURIComponent(id)}/restore`);
|
|
230
|
+
}
|
|
205
231
|
};
|
|
206
232
|
|
|
207
233
|
// ../../packages/mcp-core/dist/vault.js
|
|
@@ -443,6 +469,44 @@ async function decryptTitleField(key, encryptedTitle, ivBase64) {
|
|
|
443
469
|
return new TextDecoder().decode(decrypted);
|
|
444
470
|
}
|
|
445
471
|
|
|
472
|
+
// ../../packages/crypto/dist/attachments.js
|
|
473
|
+
var LEGACY_ATTACHMENT_MESSAGE = "This file uses an older format. Open it in the Virlow app.";
|
|
474
|
+
var LegacyAttachmentError = class extends Error {
|
|
475
|
+
constructor() {
|
|
476
|
+
super(LEGACY_ATTACHMENT_MESSAGE);
|
|
477
|
+
this.name = "LegacyAttachmentError";
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
function isV2Attachment(encryptedData) {
|
|
481
|
+
return encryptedData.startsWith("v2:");
|
|
482
|
+
}
|
|
483
|
+
async function encryptAttachment(key, filename, bytes) {
|
|
484
|
+
const fields = await encryptNoteFields(key, filename, bytesToBase64(bytes));
|
|
485
|
+
return { encryptedTitle: fields.encryptedTitle, encryptedData: fields.encryptedContent, iv: fields.iv };
|
|
486
|
+
}
|
|
487
|
+
async function encryptAttachmentName(key, filename) {
|
|
488
|
+
const iv = crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.IV_LENGTH));
|
|
489
|
+
const ciphertext = await crypto.subtle.encrypt({ name: ENCRYPTION_CONFIG.ALGORITHM, iv }, key, new TextEncoder().encode(filename));
|
|
490
|
+
return { encryptedTitle: bytesToBase64(ciphertext), iv: bytesToBase64(iv) };
|
|
491
|
+
}
|
|
492
|
+
async function decryptAttachmentData(key, encryptedData) {
|
|
493
|
+
if (!isV2Attachment(encryptedData))
|
|
494
|
+
throw new LegacyAttachmentError();
|
|
495
|
+
const rest = encryptedData.slice(3);
|
|
496
|
+
const sep = rest.indexOf(":");
|
|
497
|
+
if (sep === -1) {
|
|
498
|
+
throw new Error("Malformed attachment ciphertext: missing the IV separator");
|
|
499
|
+
}
|
|
500
|
+
const iv = base64ToBytes(rest.slice(0, sep));
|
|
501
|
+
const plain = await crypto.subtle.decrypt({ name: ENCRYPTION_CONFIG.ALGORITHM, iv }, key, base64ToArrayBuffer(rest.slice(sep + 1)));
|
|
502
|
+
return base64ToBytes(new TextDecoder().decode(plain));
|
|
503
|
+
}
|
|
504
|
+
async function decryptAttachment(key, enc) {
|
|
505
|
+
const bytes = await decryptAttachmentData(key, enc.encryptedData);
|
|
506
|
+
const filename = await decryptTitleField(key, enc.encryptedTitle, enc.iv);
|
|
507
|
+
return { filename, bytes };
|
|
508
|
+
}
|
|
509
|
+
|
|
446
510
|
// ../../packages/crypto/dist/verifier.js
|
|
447
511
|
async function verifyVerifierBlob(key, blob) {
|
|
448
512
|
try {
|
|
@@ -1752,6 +1816,342 @@ var NotesService = class {
|
|
|
1752
1816
|
}
|
|
1753
1817
|
};
|
|
1754
1818
|
|
|
1819
|
+
// ../../packages/mcp-core/dist/attachment-format.js
|
|
1820
|
+
var FILENAME_MAX = 255;
|
|
1821
|
+
var INLINE_MAX_BYTES = 2 * 1024 * 1024;
|
|
1822
|
+
var PATH_MAX_BYTES = 35 * 1024 * 1024;
|
|
1823
|
+
var BINARY_INLINE_MAX_BYTES = 2 * 1024 * 1024;
|
|
1824
|
+
var TEXT_MAX_CHARS = 2e5;
|
|
1825
|
+
var FALLBACK_NAME = "attachment";
|
|
1826
|
+
var FORMAT_CONTROL = new RegExp("\\p{Cf}", "u");
|
|
1827
|
+
function sanitizeFilename(name) {
|
|
1828
|
+
const base = name.split(/[\\/]/).pop() ?? "";
|
|
1829
|
+
const cleaned = Array.from(base).filter((ch) => {
|
|
1830
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
1831
|
+
if (c < 32 || c === 127 || c >= 128 && c <= 159)
|
|
1832
|
+
return false;
|
|
1833
|
+
return c !== 8232 && c !== 8233 && !FORMAT_CONTROL.test(ch);
|
|
1834
|
+
}).join("").trim();
|
|
1835
|
+
if (!cleaned || cleaned === "." || cleaned === "..")
|
|
1836
|
+
return FALLBACK_NAME;
|
|
1837
|
+
if (cleaned.length <= FILENAME_MAX)
|
|
1838
|
+
return cleaned;
|
|
1839
|
+
const dot = cleaned.lastIndexOf(".");
|
|
1840
|
+
const ext = dot > 0 && cleaned.length - dot <= 16 ? cleaned.slice(dot) : "";
|
|
1841
|
+
return cleaned.slice(0, FILENAME_MAX - ext.length) + ext;
|
|
1842
|
+
}
|
|
1843
|
+
var MIME_BY_EXT = {
|
|
1844
|
+
txt: "text/plain",
|
|
1845
|
+
log: "text/plain",
|
|
1846
|
+
md: "text/markdown",
|
|
1847
|
+
markdown: "text/markdown",
|
|
1848
|
+
csv: "text/csv",
|
|
1849
|
+
json: "application/json",
|
|
1850
|
+
xml: "application/xml",
|
|
1851
|
+
html: "text/html",
|
|
1852
|
+
htm: "text/html",
|
|
1853
|
+
rtf: "application/rtf",
|
|
1854
|
+
pdf: "application/pdf",
|
|
1855
|
+
png: "image/png",
|
|
1856
|
+
jpg: "image/jpeg",
|
|
1857
|
+
jpeg: "image/jpeg",
|
|
1858
|
+
gif: "image/gif",
|
|
1859
|
+
webp: "image/webp",
|
|
1860
|
+
svg: "image/svg+xml",
|
|
1861
|
+
bmp: "image/bmp",
|
|
1862
|
+
tif: "image/tiff",
|
|
1863
|
+
tiff: "image/tiff",
|
|
1864
|
+
avif: "image/avif",
|
|
1865
|
+
heic: "image/heic",
|
|
1866
|
+
heif: "image/heif",
|
|
1867
|
+
ico: "image/x-icon",
|
|
1868
|
+
doc: "application/msword",
|
|
1869
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1870
|
+
xls: "application/vnd.ms-excel",
|
|
1871
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1872
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
1873
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
1874
|
+
pages: "application/vnd.apple.pages",
|
|
1875
|
+
numbers: "application/vnd.apple.numbers",
|
|
1876
|
+
zip: "application/zip",
|
|
1877
|
+
gz: "application/gzip",
|
|
1878
|
+
tar: "application/x-tar",
|
|
1879
|
+
"7z": "application/x-7z-compressed",
|
|
1880
|
+
rar: "application/vnd.rar",
|
|
1881
|
+
mp3: "audio/mpeg",
|
|
1882
|
+
m4a: "audio/x-m4a",
|
|
1883
|
+
wav: "audio/wav",
|
|
1884
|
+
ogg: "audio/ogg",
|
|
1885
|
+
aac: "audio/aac",
|
|
1886
|
+
mp4: "video/mp4",
|
|
1887
|
+
webm: "video/webm",
|
|
1888
|
+
mov: "video/quicktime",
|
|
1889
|
+
avi: "video/x-msvideo",
|
|
1890
|
+
woff: "font/woff",
|
|
1891
|
+
woff2: "font/woff2",
|
|
1892
|
+
ttf: "font/ttf",
|
|
1893
|
+
otf: "font/otf",
|
|
1894
|
+
wasm: "application/wasm"
|
|
1895
|
+
};
|
|
1896
|
+
var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1897
|
+
"js",
|
|
1898
|
+
"mjs",
|
|
1899
|
+
"cjs",
|
|
1900
|
+
"jsx",
|
|
1901
|
+
"ts",
|
|
1902
|
+
"tsx",
|
|
1903
|
+
"py",
|
|
1904
|
+
"rb",
|
|
1905
|
+
"go",
|
|
1906
|
+
"rs",
|
|
1907
|
+
"java",
|
|
1908
|
+
"kt",
|
|
1909
|
+
"swift",
|
|
1910
|
+
"c",
|
|
1911
|
+
"h",
|
|
1912
|
+
"cpp",
|
|
1913
|
+
"hpp",
|
|
1914
|
+
"cs",
|
|
1915
|
+
"php",
|
|
1916
|
+
"sh",
|
|
1917
|
+
"bash",
|
|
1918
|
+
"zsh",
|
|
1919
|
+
"ps1",
|
|
1920
|
+
"sql",
|
|
1921
|
+
"yaml",
|
|
1922
|
+
"yml",
|
|
1923
|
+
"toml",
|
|
1924
|
+
"ini",
|
|
1925
|
+
"conf",
|
|
1926
|
+
"env",
|
|
1927
|
+
"css",
|
|
1928
|
+
"scss",
|
|
1929
|
+
"less",
|
|
1930
|
+
"vue",
|
|
1931
|
+
"svelte",
|
|
1932
|
+
"lua",
|
|
1933
|
+
"r",
|
|
1934
|
+
"dart",
|
|
1935
|
+
"scala",
|
|
1936
|
+
"gradle",
|
|
1937
|
+
"dockerfile",
|
|
1938
|
+
"makefile"
|
|
1939
|
+
]);
|
|
1940
|
+
var TEXT_MIME = /* @__PURE__ */ new Set([
|
|
1941
|
+
"application/json",
|
|
1942
|
+
"application/xml",
|
|
1943
|
+
"application/javascript",
|
|
1944
|
+
"application/x-javascript",
|
|
1945
|
+
"application/x-ndjson",
|
|
1946
|
+
"application/yaml",
|
|
1947
|
+
"application/x-yaml"
|
|
1948
|
+
]);
|
|
1949
|
+
function extOf(filename) {
|
|
1950
|
+
const lower = filename.toLowerCase();
|
|
1951
|
+
const dot = lower.lastIndexOf(".");
|
|
1952
|
+
return dot === -1 ? lower : lower.slice(dot + 1);
|
|
1953
|
+
}
|
|
1954
|
+
function inferMimeType(filename) {
|
|
1955
|
+
const ext = extOf(filename);
|
|
1956
|
+
if (MIME_BY_EXT[ext])
|
|
1957
|
+
return MIME_BY_EXT[ext];
|
|
1958
|
+
if (CODE_EXTENSIONS.has(ext))
|
|
1959
|
+
return "text/plain";
|
|
1960
|
+
return "application/octet-stream";
|
|
1961
|
+
}
|
|
1962
|
+
function isTextLike(mimeType, filename) {
|
|
1963
|
+
const mime = mimeType.split(";")[0].trim().toLowerCase();
|
|
1964
|
+
if (mime.startsWith("text/") || TEXT_MIME.has(mime))
|
|
1965
|
+
return true;
|
|
1966
|
+
if (mime.endsWith("+json") || mime.endsWith("+xml"))
|
|
1967
|
+
return true;
|
|
1968
|
+
const ext = extOf(filename);
|
|
1969
|
+
if (CODE_EXTENSIONS.has(ext))
|
|
1970
|
+
return true;
|
|
1971
|
+
const byExt = MIME_BY_EXT[ext];
|
|
1972
|
+
return byExt !== void 0 && (byExt.startsWith("text/") || TEXT_MIME.has(byExt));
|
|
1973
|
+
}
|
|
1974
|
+
function formatSize(bytes) {
|
|
1975
|
+
if (bytes < 1024)
|
|
1976
|
+
return `${bytes} B`;
|
|
1977
|
+
if (bytes < 1024 * 1024)
|
|
1978
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1979
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1980
|
+
}
|
|
1981
|
+
function decodeBase64Strict(input, max = INLINE_MAX_BYTES) {
|
|
1982
|
+
const compact = input.replace(/\s+/g, "");
|
|
1983
|
+
if (compact.length > Math.ceil(max * 4 / 3) + 4) {
|
|
1984
|
+
throw new Error(`content_base64 is about ${formatSize(Math.floor(compact.length * 3 / 4))}; the limit for inline content is ${formatSize(max)}.`);
|
|
1985
|
+
}
|
|
1986
|
+
if (compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) {
|
|
1987
|
+
throw new Error("content_base64 is not valid base64.");
|
|
1988
|
+
}
|
|
1989
|
+
return base64ToBytes(compact);
|
|
1990
|
+
}
|
|
1991
|
+
function checkSize(bytes, max, label) {
|
|
1992
|
+
if (bytes.byteLength === 0) {
|
|
1993
|
+
throw new Error(`${label} is empty. There is nothing to attach.`);
|
|
1994
|
+
}
|
|
1995
|
+
if (bytes.byteLength > max) {
|
|
1996
|
+
const where = max === INLINE_MAX_BYTES ? "the limit for inline content is" : "the limit for attaching from disk is";
|
|
1997
|
+
const hint = max === INLINE_MAX_BYTES ? " Attach larger files from disk with path, using a local Virlow MCP server." : "";
|
|
1998
|
+
throw new Error(`${label} is ${formatSize(bytes.byteLength)}; ${where} ${formatSize(max)}.${hint}`);
|
|
1999
|
+
}
|
|
2000
|
+
return bytes;
|
|
2001
|
+
}
|
|
2002
|
+
function renderAttachment(info, bytes, opts) {
|
|
2003
|
+
const filename = sanitizeFilename(info.filename);
|
|
2004
|
+
if (isTextLike(info.mimeType, filename)) {
|
|
2005
|
+
const text = new TextDecoder("utf-8").decode(bytes);
|
|
2006
|
+
if (text.length <= TEXT_MAX_CHARS)
|
|
2007
|
+
return text;
|
|
2008
|
+
return `${text.slice(0, TEXT_MAX_CHARS)}
|
|
2009
|
+
|
|
2010
|
+
[truncated: showing the first ${TEXT_MAX_CHARS} of ${text.length} characters]`;
|
|
2011
|
+
}
|
|
2012
|
+
if (bytes.byteLength <= BINARY_INLINE_MAX_BYTES) {
|
|
2013
|
+
return `${filename} \xB7 ${info.mimeType} \xB7 ${formatSize(bytes.byteLength)} \xB7 base64
|
|
2014
|
+
${bytesToBase64(bytes)}`;
|
|
2015
|
+
}
|
|
2016
|
+
const size = `${filename} is ${formatSize(bytes.byteLength)}, larger than the ${formatSize(BINARY_INLINE_MAX_BYTES)} that can be returned inline.`;
|
|
2017
|
+
throw new Error(opts.canSave ? `${size} Pass save_to with an absolute path to write it to disk.` : `${size} Open it in the Virlow app, or read it through a local Virlow MCP server, which can save it to disk.`);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// ../../packages/mcp-core/dist/attachments.js
|
|
2021
|
+
var WRONG_KEY_MESSAGE = "Could not decrypt this file with your vault key. Open it in the Virlow app.";
|
|
2022
|
+
var API_LEGACY_RENAME_PREFIX = "This file uses an older format.";
|
|
2023
|
+
function renameFailure(err) {
|
|
2024
|
+
if (err instanceof ApiError && err.status === 409 && err.message.startsWith(API_LEGACY_RENAME_PREFIX)) {
|
|
2025
|
+
return new LegacyAttachmentError();
|
|
2026
|
+
}
|
|
2027
|
+
return err;
|
|
2028
|
+
}
|
|
2029
|
+
function decryptFailure(err) {
|
|
2030
|
+
if (err instanceof LegacyAttachmentError)
|
|
2031
|
+
return err;
|
|
2032
|
+
if (err instanceof Error && err.name === "OperationError")
|
|
2033
|
+
return new Error(WRONG_KEY_MESSAGE);
|
|
2034
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
2035
|
+
}
|
|
2036
|
+
function inferredMimeType(filename, fromText) {
|
|
2037
|
+
const inferred = inferMimeType(filename);
|
|
2038
|
+
return fromText && inferred === "application/octet-stream" ? "text/plain" : inferred;
|
|
2039
|
+
}
|
|
2040
|
+
var AttachmentsService = class {
|
|
2041
|
+
api;
|
|
2042
|
+
vault;
|
|
2043
|
+
constructor(api, vault) {
|
|
2044
|
+
this.api = api;
|
|
2045
|
+
this.vault = vault;
|
|
2046
|
+
}
|
|
2047
|
+
async list(noteId, includeTrashed) {
|
|
2048
|
+
const key = this.requireSession();
|
|
2049
|
+
await this.requireExposed(noteId);
|
|
2050
|
+
const live = await this.api.listFiles(noteId);
|
|
2051
|
+
const trashed = includeTrashed ? await this.api.listFiles(noteId, { trashed: true }) : [];
|
|
2052
|
+
return Promise.all([...live, ...trashed].map((file) => this.describe(key, file)));
|
|
2053
|
+
}
|
|
2054
|
+
async add(input) {
|
|
2055
|
+
if (input.bytes.byteLength === 0)
|
|
2056
|
+
throw new Error("The file is empty. There is nothing to attach.");
|
|
2057
|
+
if (input.bytes.byteLength > PATH_MAX_BYTES) {
|
|
2058
|
+
throw new Error(`The file is ${formatSize(input.bytes.byteLength)}; the most that can be attached is ${formatSize(PATH_MAX_BYTES)}.`);
|
|
2059
|
+
}
|
|
2060
|
+
const key = this.requireSession();
|
|
2061
|
+
await this.requireExposed(input.noteId);
|
|
2062
|
+
const filename = sanitizeFilename(input.filename);
|
|
2063
|
+
const encrypted = await encryptAttachment(key, filename, input.bytes);
|
|
2064
|
+
const row = await this.api.uploadFile(input.noteId, {
|
|
2065
|
+
...encrypted,
|
|
2066
|
+
// Stored for the web app's legacy per-salt key; unused under a master key.
|
|
2067
|
+
salt: bytesToBase64(crypto.getRandomValues(new Uint8Array(32))),
|
|
2068
|
+
mimeType: input.mimeType ?? inferredMimeType(filename, input.fromText === true),
|
|
2069
|
+
size: input.bytes.byteLength
|
|
2070
|
+
});
|
|
2071
|
+
return { ...infoOf(row), filename };
|
|
2072
|
+
}
|
|
2073
|
+
async read(id) {
|
|
2074
|
+
const key = this.requireSession();
|
|
2075
|
+
const meta = await this.api.getFileMeta(id);
|
|
2076
|
+
await this.requireExposed(meta.noteId);
|
|
2077
|
+
const file = await this.api.getFile(id);
|
|
2078
|
+
if (file.noteId !== meta.noteId)
|
|
2079
|
+
await this.requireExposed(file.noteId);
|
|
2080
|
+
try {
|
|
2081
|
+
const { filename, bytes } = await decryptAttachment(key, file);
|
|
2082
|
+
return { info: { ...infoOf(file), filename: sanitizeFilename(filename) }, bytes };
|
|
2083
|
+
} catch (err) {
|
|
2084
|
+
throw decryptFailure(err);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
async rename(id, newFilename) {
|
|
2088
|
+
const key = this.requireSession();
|
|
2089
|
+
const file = await this.api.getFileMeta(id);
|
|
2090
|
+
await this.requireExposed(file.noteId);
|
|
2091
|
+
const filename = sanitizeFilename(newFilename);
|
|
2092
|
+
const row = await this.api.patchFile(id, await encryptAttachmentName(key, filename)).catch((err) => {
|
|
2093
|
+
throw renameFailure(err);
|
|
2094
|
+
});
|
|
2095
|
+
return { ...infoOf(row), filename };
|
|
2096
|
+
}
|
|
2097
|
+
async move(id, toNoteId) {
|
|
2098
|
+
const key = this.requireSession();
|
|
2099
|
+
const file = await this.api.getFileMeta(id);
|
|
2100
|
+
await this.requireExposed(file.noteId, toNoteId);
|
|
2101
|
+
return this.describe(key, await this.api.patchFile(id, { noteId: toNoteId }));
|
|
2102
|
+
}
|
|
2103
|
+
async trash(id) {
|
|
2104
|
+
this.requireSession();
|
|
2105
|
+
const file = await this.api.getFileMeta(id);
|
|
2106
|
+
await this.requireExposed(file.noteId);
|
|
2107
|
+
await this.api.deleteFile(id);
|
|
2108
|
+
}
|
|
2109
|
+
async restore(id) {
|
|
2110
|
+
const key = this.requireSession();
|
|
2111
|
+
const file = await this.api.getFileMeta(id);
|
|
2112
|
+
await this.requireExposed(file.noteId);
|
|
2113
|
+
return this.describe(key, await this.api.restoreFile(id));
|
|
2114
|
+
}
|
|
2115
|
+
/** Metadata with the filename decrypted. A legacy row's placeholder title
|
|
2116
|
+
* does not decrypt; it falls back to its plaintext name, then a generic one. */
|
|
2117
|
+
async describe(key, file) {
|
|
2118
|
+
try {
|
|
2119
|
+
const filename = await decryptTitleField(key, file.encryptedTitle, file.iv);
|
|
2120
|
+
return { ...infoOf(file), filename: sanitizeFilename(filename) };
|
|
2121
|
+
} catch {
|
|
2122
|
+
return infoOf(file);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
async requireExposed(...noteIds) {
|
|
2126
|
+
const [{ folders }, ...rows] = await Promise.all([
|
|
2127
|
+
this.api.listFolders(),
|
|
2128
|
+
...noteIds.map((id) => this.api.getNote(id))
|
|
2129
|
+
]);
|
|
2130
|
+
const exposure = computeExposure(folders);
|
|
2131
|
+
for (const row of rows) {
|
|
2132
|
+
if (!exposure.allows(row.folderId)) {
|
|
2133
|
+
throw new Error(`Note ${row.id} is in a folder that is not exposed to MCP. Turn on "Expose to MCP" in that folder's settings in the Virlow app to access it here.`);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
requireSession() {
|
|
2138
|
+
const key = this.vault.session.key;
|
|
2139
|
+
this.vault.touch();
|
|
2140
|
+
return key;
|
|
2141
|
+
}
|
|
2142
|
+
};
|
|
2143
|
+
function infoOf(file) {
|
|
2144
|
+
return {
|
|
2145
|
+
id: file.id,
|
|
2146
|
+
noteId: file.noteId,
|
|
2147
|
+
filename: sanitizeFilename(file.originalName ?? "attachment"),
|
|
2148
|
+
mimeType: file.mimeType,
|
|
2149
|
+
size: file.size,
|
|
2150
|
+
uploadedAt: file.uploadedAt,
|
|
2151
|
+
deletedAt: file.deletedAt ?? null
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
|
|
1755
2155
|
// ../../packages/memory-core/dist/constants.js
|
|
1756
2156
|
var EMBEDDING_MODEL_ID = "Xenova/all-MiniLM-L6-v2";
|
|
1757
2157
|
var EMBEDDING_MODEL_REVISION = "751bff37182d3f1213fa05d7196b954e230abad9";
|
|
@@ -3031,7 +3431,154 @@ function memoriesUnreadableMessage(err) {
|
|
|
3031
3431
|
return `Could not check whether AI memories are on: ${reason}. Nothing was changed. Wait a minute and try again; this is not the AI memories switch.`;
|
|
3032
3432
|
}
|
|
3033
3433
|
|
|
3434
|
+
// ../../packages/mcp-core/dist/http/server.js
|
|
3435
|
+
import { createServer } from "node:http";
|
|
3436
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
3437
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3438
|
+
import path2 from "node:path";
|
|
3439
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3440
|
+
|
|
3441
|
+
// ../../packages/mcp-core/dist/http/local-origin.js
|
|
3442
|
+
function isLoopbackBind(host) {
|
|
3443
|
+
const h = host.trim().toLowerCase();
|
|
3444
|
+
if (h === "localhost" || h === "::1" || h === "[::1]")
|
|
3445
|
+
return true;
|
|
3446
|
+
const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
|
|
3447
|
+
if (m === null)
|
|
3448
|
+
return false;
|
|
3449
|
+
return m.slice(1).every((o) => Number(o) <= 255 && (o === "0" || !o.startsWith("0")));
|
|
3450
|
+
}
|
|
3451
|
+
function withoutPort(host) {
|
|
3452
|
+
return host.replace(/:\d+$/, "").toLowerCase();
|
|
3453
|
+
}
|
|
3454
|
+
function isLoopbackHost(hostHeader) {
|
|
3455
|
+
if (hostHeader === void 0 || hostHeader === "")
|
|
3456
|
+
return false;
|
|
3457
|
+
return isLoopbackBind(withoutPort(hostHeader.trim()));
|
|
3458
|
+
}
|
|
3459
|
+
function isAllowedOrigin(originHeader) {
|
|
3460
|
+
if (originHeader === void 0 || originHeader === "")
|
|
3461
|
+
return true;
|
|
3462
|
+
try {
|
|
3463
|
+
return isLoopbackBind(new URL(originHeader).hostname);
|
|
3464
|
+
} catch {
|
|
3465
|
+
return false;
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
function rejectReason(headers, enforce) {
|
|
3469
|
+
if (!enforce)
|
|
3470
|
+
return null;
|
|
3471
|
+
if (!isLoopbackHost(headers.host))
|
|
3472
|
+
return "non-loopback Host";
|
|
3473
|
+
if (!isAllowedOrigin(headers.origin))
|
|
3474
|
+
return "cross-site Origin";
|
|
3475
|
+
return null;
|
|
3476
|
+
}
|
|
3477
|
+
var SECURITY_HEADERS = Object.freeze({
|
|
3478
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
3479
|
+
"x-frame-options": "DENY",
|
|
3480
|
+
"x-content-type-options": "nosniff",
|
|
3481
|
+
"referrer-policy": "same-origin",
|
|
3482
|
+
"cache-control": "no-store"
|
|
3483
|
+
});
|
|
3484
|
+
|
|
3485
|
+
// ../../packages/mcp-core/dist/http/server.js
|
|
3486
|
+
function defaultTokenPath(homeDir) {
|
|
3487
|
+
return path2.join(homeDir, ".virlow-mcp", "http-token");
|
|
3488
|
+
}
|
|
3489
|
+
async function loadOrCreateToken(tokenPath) {
|
|
3490
|
+
try {
|
|
3491
|
+
const existing = (await readFile2(tokenPath, "utf8")).trim();
|
|
3492
|
+
if (existing.length > 0)
|
|
3493
|
+
return existing;
|
|
3494
|
+
} catch {
|
|
3495
|
+
}
|
|
3496
|
+
const token = randomBytes(32).toString("base64url");
|
|
3497
|
+
await mkdir2(path2.dirname(tokenPath), { recursive: true, mode: 448 });
|
|
3498
|
+
await writeFile2(tokenPath, `${token}
|
|
3499
|
+
`, { mode: 384 });
|
|
3500
|
+
return token;
|
|
3501
|
+
}
|
|
3502
|
+
function tokenMatches(provided, expected) {
|
|
3503
|
+
const a = Buffer.from(provided);
|
|
3504
|
+
const b = Buffer.from(expected);
|
|
3505
|
+
if (a.length !== b.length) {
|
|
3506
|
+
timingSafeEqual(b, b);
|
|
3507
|
+
return false;
|
|
3508
|
+
}
|
|
3509
|
+
return timingSafeEqual(a, b);
|
|
3510
|
+
}
|
|
3511
|
+
function bearerFrom(header) {
|
|
3512
|
+
if (!header)
|
|
3513
|
+
return null;
|
|
3514
|
+
const match = /^Bearer[ ]+(.+)$/i.exec(header.trim());
|
|
3515
|
+
return match ? match[1].trim() : null;
|
|
3516
|
+
}
|
|
3517
|
+
async function handleWithFreshTransport(server, req, res) {
|
|
3518
|
+
const transport = new StreamableHTTPServerTransport({
|
|
3519
|
+
sessionIdGenerator: void 0
|
|
3520
|
+
});
|
|
3521
|
+
res.on("close", () => {
|
|
3522
|
+
void transport.close();
|
|
3523
|
+
void server.close();
|
|
3524
|
+
});
|
|
3525
|
+
await server.connect(transport);
|
|
3526
|
+
await transport.handleRequest(req, res);
|
|
3527
|
+
}
|
|
3528
|
+
async function startHttpServer(opts) {
|
|
3529
|
+
const host = opts.host ?? "127.0.0.1";
|
|
3530
|
+
const mcpPath = opts.path ?? "/mcp";
|
|
3531
|
+
const enforceLocal = isLoopbackBind(host);
|
|
3532
|
+
const httpServer = createServer((req, res) => {
|
|
3533
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
3534
|
+
if (rejectReason(req.headers, enforceLocal) !== null) {
|
|
3535
|
+
res.writeHead(403, { ...SECURITY_HEADERS, "content-type": "application/json" });
|
|
3536
|
+
res.end(JSON.stringify({ error: "forbidden" }));
|
|
3537
|
+
return;
|
|
3538
|
+
}
|
|
3539
|
+
if (url.pathname === "/health") {
|
|
3540
|
+
res.writeHead(200, { ...SECURITY_HEADERS, "content-type": "application/json" });
|
|
3541
|
+
res.end(JSON.stringify({ ok: true }));
|
|
3542
|
+
return;
|
|
3543
|
+
}
|
|
3544
|
+
if (url.pathname !== mcpPath) {
|
|
3545
|
+
res.writeHead(404, SECURITY_HEADERS).end();
|
|
3546
|
+
return;
|
|
3547
|
+
}
|
|
3548
|
+
const provided = bearerFrom(req.headers.authorization);
|
|
3549
|
+
if (provided === null || opts.authorize(provided) === null) {
|
|
3550
|
+
res.writeHead(401, {
|
|
3551
|
+
...SECURITY_HEADERS,
|
|
3552
|
+
"content-type": "application/json",
|
|
3553
|
+
"www-authenticate": "Bearer"
|
|
3554
|
+
});
|
|
3555
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
void handleWithFreshTransport(opts.makeServer(), req, res);
|
|
3559
|
+
});
|
|
3560
|
+
await new Promise((resolve, reject) => {
|
|
3561
|
+
httpServer.once("error", reject);
|
|
3562
|
+
httpServer.listen(opts.port ?? 0, host, () => {
|
|
3563
|
+
httpServer.removeListener("error", reject);
|
|
3564
|
+
resolve();
|
|
3565
|
+
});
|
|
3566
|
+
});
|
|
3567
|
+
const address = httpServer.address();
|
|
3568
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
3569
|
+
return {
|
|
3570
|
+
port,
|
|
3571
|
+
host,
|
|
3572
|
+
close: () => new Promise((resolve) => {
|
|
3573
|
+
httpServer.close(() => resolve());
|
|
3574
|
+
})
|
|
3575
|
+
};
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3034
3578
|
// ../../packages/mcp-core/dist/tools.js
|
|
3579
|
+
import { constants as fsConstants } from "node:fs";
|
|
3580
|
+
import { lstat, open, realpath, writeFile as writeFile3 } from "node:fs/promises";
|
|
3581
|
+
import { basename, dirname, join } from "node:path";
|
|
3035
3582
|
import { z } from "zod";
|
|
3036
3583
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3037
3584
|
|
|
@@ -3056,6 +3603,28 @@ function projectPausedMessage(namespace) {
|
|
|
3056
3603
|
return `Not saved: saving memories is paused for the project "${namespace}" in the Virlow app. Recall still works; do not retry or ask the user to save this another way.`;
|
|
3057
3604
|
}
|
|
3058
3605
|
|
|
3606
|
+
// ../../packages/mcp-core/dist/local-paths.js
|
|
3607
|
+
var currentPlatform = () => process.platform === "win32" ? "win32" : "posix";
|
|
3608
|
+
var WIN_RESERVED = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)(\..*)?$/i;
|
|
3609
|
+
function isLocalWindowsPath(p) {
|
|
3610
|
+
if (!/^[A-Za-z]:[\\/]/.test(p))
|
|
3611
|
+
return false;
|
|
3612
|
+
if (p.indexOf(":", 2) !== -1)
|
|
3613
|
+
return false;
|
|
3614
|
+
return !p.slice(3).split(/[\\/]/).some((segment) => WIN_RESERVED.test(segment.replace(/[ .]+$/, "")));
|
|
3615
|
+
}
|
|
3616
|
+
function requireLocalAbsolutePath(p, label, platform = currentPlatform()) {
|
|
3617
|
+
if (platform === "win32") {
|
|
3618
|
+
const candidate = /^\\\\\?\\[A-Za-z]:\\/.test(p) ? p.slice(4) : p;
|
|
3619
|
+
if (isLocalWindowsPath(candidate))
|
|
3620
|
+
return candidate;
|
|
3621
|
+
throw new Error(`${label} must be a full local path like C:\\Users\\you\\file.pdf, got "${p}". Network and device paths are not allowed.`);
|
|
3622
|
+
}
|
|
3623
|
+
if (p.startsWith("/") && !p.startsWith("//"))
|
|
3624
|
+
return p;
|
|
3625
|
+
throw new Error(`${label} must be an absolute path like /home/you/file.pdf, got "${p}".`);
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3059
3628
|
// ../../packages/mcp-core/dist/tools.js
|
|
3060
3629
|
function textResult(text) {
|
|
3061
3630
|
return { content: [{ type: "text", text }] };
|
|
@@ -3145,6 +3714,118 @@ var relationSchema = z.object({
|
|
|
3145
3714
|
to: z.string().max(40).optional()
|
|
3146
3715
|
});
|
|
3147
3716
|
var truncate = (text, max = 120) => text.length > max ? `${text.slice(0, max)}\u2026` : text;
|
|
3717
|
+
var errnoOf = (err) => err?.code;
|
|
3718
|
+
async function attachmentBytes(args, fileAccess) {
|
|
3719
|
+
const given = [args.text, args.content_base64, fileAccess ? args.path : void 0].filter((v) => v !== void 0).length;
|
|
3720
|
+
if (given !== 1) {
|
|
3721
|
+
throw new Error(fileAccess ? "Pass exactly one of text, content_base64 or path." : "Pass exactly one of text or content_base64.");
|
|
3722
|
+
}
|
|
3723
|
+
if (args.text !== void 0) {
|
|
3724
|
+
if (args.text.length > INLINE_MAX_BYTES) {
|
|
3725
|
+
throw new Error(`text is over ${formatSize(INLINE_MAX_BYTES)}; the limit for inline content is ${formatSize(INLINE_MAX_BYTES)}.`);
|
|
3726
|
+
}
|
|
3727
|
+
return checkSize(new TextEncoder().encode(args.text), INLINE_MAX_BYTES, "text");
|
|
3728
|
+
}
|
|
3729
|
+
if (args.content_base64 !== void 0) {
|
|
3730
|
+
return checkSize(decodeBase64Strict(args.content_base64, INLINE_MAX_BYTES), INLINE_MAX_BYTES, "content_base64");
|
|
3731
|
+
}
|
|
3732
|
+
return readLocalFile(requireLocalAbsolutePath(args.path, "path"));
|
|
3733
|
+
}
|
|
3734
|
+
async function readLocalFile(file) {
|
|
3735
|
+
const handle = await open(file, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0)).catch((err) => {
|
|
3736
|
+
const code = errnoOf(err);
|
|
3737
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
3738
|
+
throw new Error(`${file} does not exist.`);
|
|
3739
|
+
if (code === "EISDIR")
|
|
3740
|
+
throw new Error(`${file} is not a regular file.`);
|
|
3741
|
+
throw err;
|
|
3742
|
+
});
|
|
3743
|
+
try {
|
|
3744
|
+
const info = await handle.stat();
|
|
3745
|
+
if (!info.isFile())
|
|
3746
|
+
throw new Error(`${file} is not a regular file.`);
|
|
3747
|
+
const tooBig = (size) => new Error(`${file} is ${formatSize(size)}; the limit for attaching from disk is ${formatSize(PATH_MAX_BYTES)}.`);
|
|
3748
|
+
if (info.size > PATH_MAX_BYTES)
|
|
3749
|
+
throw tooBig(info.size);
|
|
3750
|
+
const cap = PATH_MAX_BYTES + 1;
|
|
3751
|
+
let buf = Buffer.allocUnsafe(Math.min(info.size + 1, cap));
|
|
3752
|
+
let total = 0;
|
|
3753
|
+
for (; ; ) {
|
|
3754
|
+
if (total === buf.length) {
|
|
3755
|
+
if (buf.length === cap)
|
|
3756
|
+
break;
|
|
3757
|
+
const grown = Buffer.allocUnsafe(Math.min(buf.length * 2, cap));
|
|
3758
|
+
buf.copy(grown, 0, 0, total);
|
|
3759
|
+
buf = grown;
|
|
3760
|
+
}
|
|
3761
|
+
const { bytesRead } = await handle.read(buf, total, buf.length - total, null);
|
|
3762
|
+
if (bytesRead === 0)
|
|
3763
|
+
break;
|
|
3764
|
+
total += bytesRead;
|
|
3765
|
+
}
|
|
3766
|
+
if (total > PATH_MAX_BYTES)
|
|
3767
|
+
throw tooBig(total);
|
|
3768
|
+
return checkSize(new Uint8Array(buf.subarray(0, total)), PATH_MAX_BYTES, file);
|
|
3769
|
+
} finally {
|
|
3770
|
+
await handle.close();
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
async function saveAttachment(saveTo, bytes, overwrite) {
|
|
3774
|
+
const asked = requireLocalAbsolutePath(saveTo, "save_to");
|
|
3775
|
+
const noFolder = `The folder for ${saveTo} does not exist. Create it first; read_attachment does not create folders.`;
|
|
3776
|
+
const realParent = await realpath(dirname(asked)).catch((err) => {
|
|
3777
|
+
const code = errnoOf(err);
|
|
3778
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
3779
|
+
throw new Error(noFolder);
|
|
3780
|
+
throw err;
|
|
3781
|
+
});
|
|
3782
|
+
try {
|
|
3783
|
+
requireLocalAbsolutePath(realParent, "save_to");
|
|
3784
|
+
} catch {
|
|
3785
|
+
throw new Error(`The folder for ${saveTo} resolves to ${realParent}, which is not on a local disk. read_attachment only writes to local folders.`);
|
|
3786
|
+
}
|
|
3787
|
+
const target = join(realParent, basename(asked));
|
|
3788
|
+
const linkMessage = `${saveTo} is a symbolic link. read_attachment does not write through links.`;
|
|
3789
|
+
const existing = await lstat(target).catch((err) => {
|
|
3790
|
+
if (errnoOf(err) === "ENOENT")
|
|
3791
|
+
return null;
|
|
3792
|
+
throw err;
|
|
3793
|
+
});
|
|
3794
|
+
if (existing) {
|
|
3795
|
+
if (existing.isSymbolicLink())
|
|
3796
|
+
throw new Error(linkMessage);
|
|
3797
|
+
if (existing.isDirectory())
|
|
3798
|
+
throw new Error(`${saveTo} is a directory, not a file.`);
|
|
3799
|
+
if (!overwrite)
|
|
3800
|
+
throw new Error(`${saveTo} already exists. Pass overwrite: true to replace it.`);
|
|
3801
|
+
}
|
|
3802
|
+
const flag = overwrite ? fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | (fsConstants.O_NOFOLLOW ?? 0) : "wx";
|
|
3803
|
+
try {
|
|
3804
|
+
await writeFile3(target, bytes, { flag });
|
|
3805
|
+
} catch (err) {
|
|
3806
|
+
const code = errnoOf(err);
|
|
3807
|
+
if (code === "EEXIST")
|
|
3808
|
+
throw new Error(`${saveTo} already exists. Pass overwrite: true to replace it.`);
|
|
3809
|
+
if (code === "ELOOP")
|
|
3810
|
+
throw new Error(linkMessage);
|
|
3811
|
+
if (code === "EISDIR")
|
|
3812
|
+
throw new Error(`${saveTo} is a directory, not a file.`);
|
|
3813
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
3814
|
+
throw new Error(noFolder);
|
|
3815
|
+
}
|
|
3816
|
+
throw err;
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
function attachmentListing(files) {
|
|
3820
|
+
return JSON.stringify(files.map((f) => ({
|
|
3821
|
+
id: f.id,
|
|
3822
|
+
filename: f.filename,
|
|
3823
|
+
mime_type: f.mimeType,
|
|
3824
|
+
size: f.size,
|
|
3825
|
+
uploaded_at: f.uploadedAt,
|
|
3826
|
+
...f.deletedAt ? { deleted_at: f.deletedAt } : {}
|
|
3827
|
+
})), null, 2);
|
|
3828
|
+
}
|
|
3148
3829
|
function createServices(opts) {
|
|
3149
3830
|
const api = opts.api ?? new VirlowApi(opts.apiUrl);
|
|
3150
3831
|
const vault = new Vault(opts.autoLockMs);
|
|
@@ -3693,151 +4374,110 @@ ${lines.join("\n")}`);
|
|
|
3693
4374
|
const folder = await notesService.createFolder(args.name, args.parentId);
|
|
3694
4375
|
return textResult(`created folder ${folder.id}`);
|
|
3695
4376
|
}));
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
// ../../packages/mcp-core/dist/http/server.js
|
|
3705
|
-
import { createServer } from "node:http";
|
|
3706
|
-
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
3707
|
-
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3708
|
-
import path2 from "node:path";
|
|
3709
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3710
|
-
|
|
3711
|
-
// ../../packages/mcp-core/dist/http/local-origin.js
|
|
3712
|
-
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
3713
|
-
function withoutPort(host) {
|
|
3714
|
-
return host.replace(/:\d+$/, "").toLowerCase();
|
|
3715
|
-
}
|
|
3716
|
-
function isLoopbackHost(hostHeader) {
|
|
3717
|
-
if (hostHeader === void 0 || hostHeader === "")
|
|
3718
|
-
return false;
|
|
3719
|
-
return LOOPBACK_HOSTS.has(withoutPort(hostHeader.trim()));
|
|
3720
|
-
}
|
|
3721
|
-
function isAllowedOrigin(originHeader) {
|
|
3722
|
-
if (originHeader === void 0 || originHeader === "")
|
|
3723
|
-
return true;
|
|
3724
|
-
try {
|
|
3725
|
-
return LOOPBACK_HOSTS.has(new URL(originHeader).hostname.toLowerCase());
|
|
3726
|
-
} catch {
|
|
3727
|
-
return false;
|
|
3728
|
-
}
|
|
3729
|
-
}
|
|
3730
|
-
function rejectReason(headers, enforce) {
|
|
3731
|
-
if (!enforce)
|
|
3732
|
-
return null;
|
|
3733
|
-
if (!isLoopbackHost(headers.host))
|
|
3734
|
-
return "non-loopback Host";
|
|
3735
|
-
if (!isAllowedOrigin(headers.origin))
|
|
3736
|
-
return "cross-site Origin";
|
|
3737
|
-
return null;
|
|
3738
|
-
}
|
|
3739
|
-
var SECURITY_HEADERS = Object.freeze({
|
|
3740
|
-
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
3741
|
-
"x-frame-options": "DENY",
|
|
3742
|
-
"x-content-type-options": "nosniff",
|
|
3743
|
-
"referrer-policy": "same-origin",
|
|
3744
|
-
"cache-control": "no-store"
|
|
3745
|
-
});
|
|
3746
|
-
|
|
3747
|
-
// ../../packages/mcp-core/dist/http/server.js
|
|
3748
|
-
function defaultTokenPath(homeDir) {
|
|
3749
|
-
return path2.join(homeDir, ".virlow-mcp", "http-token");
|
|
3750
|
-
}
|
|
3751
|
-
async function loadOrCreateToken(tokenPath) {
|
|
3752
|
-
try {
|
|
3753
|
-
const existing = (await readFile2(tokenPath, "utf8")).trim();
|
|
3754
|
-
if (existing.length > 0)
|
|
3755
|
-
return existing;
|
|
3756
|
-
} catch {
|
|
3757
|
-
}
|
|
3758
|
-
const token = randomBytes(32).toString("base64url");
|
|
3759
|
-
await mkdir2(path2.dirname(tokenPath), { recursive: true, mode: 448 });
|
|
3760
|
-
await writeFile2(tokenPath, `${token}
|
|
3761
|
-
`, { mode: 384 });
|
|
3762
|
-
return token;
|
|
3763
|
-
}
|
|
3764
|
-
function tokenMatches(provided, expected) {
|
|
3765
|
-
const a = Buffer.from(provided);
|
|
3766
|
-
const b = Buffer.from(expected);
|
|
3767
|
-
if (a.length !== b.length) {
|
|
3768
|
-
timingSafeEqual(b, b);
|
|
3769
|
-
return false;
|
|
3770
|
-
}
|
|
3771
|
-
return timingSafeEqual(a, b);
|
|
3772
|
-
}
|
|
3773
|
-
function bearerFrom(header) {
|
|
3774
|
-
if (!header)
|
|
3775
|
-
return null;
|
|
3776
|
-
const match = /^Bearer[ ]+(.+)$/i.exec(header.trim());
|
|
3777
|
-
return match ? match[1].trim() : null;
|
|
3778
|
-
}
|
|
3779
|
-
async function handleWithFreshTransport(server, req, res) {
|
|
3780
|
-
const transport = new StreamableHTTPServerTransport({
|
|
3781
|
-
sessionIdGenerator: void 0
|
|
3782
|
-
});
|
|
3783
|
-
res.on("close", () => {
|
|
3784
|
-
void transport.close();
|
|
3785
|
-
void server.close();
|
|
3786
|
-
});
|
|
3787
|
-
await server.connect(transport);
|
|
3788
|
-
await transport.handleRequest(req, res);
|
|
3789
|
-
}
|
|
3790
|
-
async function startHttpServer(opts) {
|
|
3791
|
-
const host = opts.host ?? "127.0.0.1";
|
|
3792
|
-
const mcpPath = opts.path ?? "/mcp";
|
|
3793
|
-
const enforceLocal = host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
3794
|
-
const httpServer = createServer((req, res) => {
|
|
3795
|
-
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
3796
|
-
if (rejectReason(req.headers, enforceLocal) !== null) {
|
|
3797
|
-
res.writeHead(403, { ...SECURITY_HEADERS, "content-type": "application/json" });
|
|
3798
|
-
res.end(JSON.stringify({ error: "forbidden" }));
|
|
3799
|
-
return;
|
|
4377
|
+
const attachments = new AttachmentsService(api, vault);
|
|
4378
|
+
const fileAccess = opts.fileAccess === true;
|
|
4379
|
+
server.registerTool("list_attachments", {
|
|
4380
|
+
description: "List a note's attachments as JSON: id, filename, mime_type, size (bytes) and uploaded_at. Pass include_trashed to also list files in the note's Recently deleted, which carry deleted_at. Requires the vault to be unlocked and the note to be in a folder exposed to AI.",
|
|
4381
|
+
inputSchema: {
|
|
4382
|
+
note_id: z.string().min(1),
|
|
4383
|
+
include_trashed: z.boolean().optional()
|
|
3800
4384
|
}
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
4385
|
+
}, wrap(async (args) => {
|
|
4386
|
+
const files = await attachments.list(args.note_id, args.include_trashed === true);
|
|
4387
|
+
return textResult(attachmentListing(files));
|
|
4388
|
+
}));
|
|
4389
|
+
const addBase = {
|
|
4390
|
+
note_id: z.string().min(1),
|
|
4391
|
+
filename: z.string().min(1).max(1e3).describe("Shown in the app. Only the basename is kept."),
|
|
4392
|
+
mime_type: z.string().min(1).max(100).optional().describe("Inferred from the filename when omitted; must be a type Virlow accepts."),
|
|
4393
|
+
text: z.string().optional().describe("UTF-8 content, up to 2 MB."),
|
|
4394
|
+
content_base64: z.string().optional().describe("Binary content as base64, up to 2 MB decoded.")
|
|
4395
|
+
};
|
|
4396
|
+
const addDescription = "Attach a file to a note. Give exactly one source: " + (fileAccess ? "text, content_base64, or path. text and content_base64 take up to 2 MB; path is an absolute path to a regular file on this machine's own disk, up to 35 MB. Symbolic links in path are followed; network and device paths are refused" : "text or content_base64, each up to 2 MB. Larger files can be attached from disk only through a local Virlow MCP server") + ". The file is encrypted on this side before upload, the same way the Virlow app does it. Requires the vault to be unlocked and the note to be in a folder exposed to AI.";
|
|
4397
|
+
const addHandler = wrap(async (args) => {
|
|
4398
|
+
const bytes = await attachmentBytes(args, fileAccess);
|
|
4399
|
+
const info = await attachments.add({
|
|
4400
|
+
noteId: args.note_id,
|
|
4401
|
+
filename: args.filename,
|
|
4402
|
+
mimeType: args.mime_type,
|
|
4403
|
+
fromText: args.text !== void 0,
|
|
4404
|
+
bytes
|
|
4405
|
+
});
|
|
4406
|
+
return textResult(`attached ${info.filename} (${formatSize(info.size)}) to note ${args.note_id} as ${info.id}`);
|
|
4407
|
+
});
|
|
4408
|
+
if (fileAccess) {
|
|
4409
|
+
server.registerTool("add_attachment", {
|
|
4410
|
+
description: addDescription,
|
|
4411
|
+
inputSchema: {
|
|
4412
|
+
...addBase,
|
|
4413
|
+
path: z.string().min(1).optional().describe("Absolute local path of a regular file, up to 35 MB (a drive path on Windows). Symbolic links are followed. Network and device paths are refused.")
|
|
4414
|
+
}
|
|
4415
|
+
}, addHandler);
|
|
4416
|
+
} else {
|
|
4417
|
+
server.registerTool("add_attachment", { description: addDescription, inputSchema: addBase }, addHandler);
|
|
4418
|
+
}
|
|
4419
|
+
const readDescription = "Read an attachment. Text files (plain text, Markdown, CSV, JSON, XML, code) come back as text, cut at 200,000 characters. Other files up to 2 MB come back as base64 after a header line giving the filename, type and size" + (fileAccess ? "; larger ones need save_to, an absolute path on this machine's own disk to write the decrypted file to. save_to never replaces an existing file unless overwrite is true, never writes through a symbolic link, and never creates folders" : "; larger ones cannot be returned here") + ". Files in Recently deleted can be read too. Requires the vault to be unlocked and the note to be in a folder exposed to AI.";
|
|
4420
|
+
const readHandler = wrap(async (args) => {
|
|
4421
|
+
const { info, bytes } = await attachments.read(args.attachment_id);
|
|
4422
|
+
if (fileAccess && args.save_to !== void 0) {
|
|
4423
|
+
await saveAttachment(args.save_to, bytes, args.overwrite === true);
|
|
4424
|
+
return textResult(`saved ${info.filename} (${formatSize(bytes.byteLength)}) to ${args.save_to}`);
|
|
3805
4425
|
}
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
4426
|
+
return textResult(renderAttachment(info, bytes, { canSave: fileAccess }));
|
|
4427
|
+
});
|
|
4428
|
+
if (fileAccess) {
|
|
4429
|
+
server.registerTool("read_attachment", {
|
|
4430
|
+
description: readDescription,
|
|
4431
|
+
inputSchema: {
|
|
4432
|
+
attachment_id: z.string().min(1),
|
|
4433
|
+
save_to: z.string().min(1).optional().describe("Absolute local path to write the decrypted file to (a drive path on Windows). Network and device paths are refused."),
|
|
4434
|
+
overwrite: z.boolean().optional().describe("Replace an existing regular file at save_to.")
|
|
4435
|
+
}
|
|
4436
|
+
}, readHandler);
|
|
4437
|
+
} else {
|
|
4438
|
+
server.registerTool("read_attachment", { description: readDescription, inputSchema: { attachment_id: z.string().min(1) } }, readHandler);
|
|
4439
|
+
}
|
|
4440
|
+
server.registerTool("rename_attachment", {
|
|
4441
|
+
description: "Rename an attachment. Only the encrypted filename changes; the contents are untouched. Requires the vault to be unlocked and the note to be in a folder exposed to AI.",
|
|
4442
|
+
inputSchema: {
|
|
4443
|
+
attachment_id: z.string().min(1),
|
|
4444
|
+
new_filename: z.string().min(1).max(1e3)
|
|
3809
4445
|
}
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
4446
|
+
}, wrap(async (args) => {
|
|
4447
|
+
const info = await attachments.rename(args.attachment_id, args.new_filename);
|
|
4448
|
+
return textResult(`renamed attachment ${args.attachment_id} to ${info.filename}`);
|
|
4449
|
+
}));
|
|
4450
|
+
server.registerTool("move_attachment", {
|
|
4451
|
+
description: "Move an attachment to another note. Both notes must be in folders exposed to AI, and the target must not be in the trash. Requires the vault to be unlocked.",
|
|
4452
|
+
inputSchema: {
|
|
4453
|
+
attachment_id: z.string().min(1),
|
|
4454
|
+
to_note_id: z.string().min(1)
|
|
3819
4455
|
}
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
})
|
|
3837
|
-
};
|
|
4456
|
+
}, wrap(async (args) => {
|
|
4457
|
+
await attachments.move(args.attachment_id, args.to_note_id);
|
|
4458
|
+
return textResult(`moved attachment ${args.attachment_id} to note ${args.to_note_id}`);
|
|
4459
|
+
}));
|
|
4460
|
+
server.registerTool("delete_attachment", {
|
|
4461
|
+
description: "Move an attachment to its note's Recently deleted. It can be restored for 30 days with restore_attachment, then it is deleted for good. Requires the vault to be unlocked and the note to be in a folder exposed to AI.",
|
|
4462
|
+
inputSchema: { attachment_id: z.string().min(1) }
|
|
4463
|
+
}, wrap(async (args) => {
|
|
4464
|
+
await attachments.trash(args.attachment_id);
|
|
4465
|
+
return textResult(`moved attachment ${args.attachment_id} to Recently deleted. It can be restored for 30 days with restore_attachment.`);
|
|
4466
|
+
}));
|
|
4467
|
+
server.registerTool("restore_attachment", {
|
|
4468
|
+
description: "Bring an attachment back from its note's Recently deleted. Fails if the note is at its attachment size limit. Requires the vault to be unlocked and the note to be in a folder exposed to AI.",
|
|
4469
|
+
inputSchema: { attachment_id: z.string().min(1) }
|
|
4470
|
+
}, wrap(async (args) => {
|
|
4471
|
+
await attachments.restore(args.attachment_id);
|
|
4472
|
+
return textResult(`restored attachment ${args.attachment_id}`);
|
|
4473
|
+
}));
|
|
4474
|
+
return { server, vault };
|
|
3838
4475
|
}
|
|
3839
4476
|
|
|
3840
4477
|
// src/unlock.ts
|
|
4478
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
4479
|
+
import { spawn } from "node:child_process";
|
|
4480
|
+
import * as http from "node:http";
|
|
3841
4481
|
var UNLOCK_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
3842
4482
|
var MAX_BAD_NONCE_ATTEMPTS = 3;
|
|
3843
4483
|
async function performUnlock(api, vault, creds) {
|
|
@@ -4102,7 +4742,7 @@ function startUnlockServer(api, vault, opts = {}) {
|
|
|
4102
4742
|
}
|
|
4103
4743
|
|
|
4104
4744
|
// src/version.ts
|
|
4105
|
-
var VERSION = true ? "3.
|
|
4745
|
+
var VERSION = true ? "3.81.0" : "dev";
|
|
4106
4746
|
|
|
4107
4747
|
// src/server.ts
|
|
4108
4748
|
function defaultEmbeddingCacheDir() {
|
|
@@ -4128,6 +4768,9 @@ function withLocalDefaults(opts) {
|
|
|
4128
4768
|
})
|
|
4129
4769
|
};
|
|
4130
4770
|
}
|
|
4771
|
+
function localFileAccess(opts) {
|
|
4772
|
+
return !opts.http || isLoopbackBind(opts.host ?? "127.0.0.1");
|
|
4773
|
+
}
|
|
4131
4774
|
function createServices2(opts) {
|
|
4132
4775
|
return createServices(withLocalDefaults(opts));
|
|
4133
4776
|
}
|
|
@@ -4214,15 +4857,17 @@ async function main() {
|
|
|
4214
4857
|
const modelCacheDir = path4.join(os2.homedir(), ".virlow-mcp", "models");
|
|
4215
4858
|
const embeddingCacheDir = path4.join(os2.homedir(), ".virlow-mcp", "cache");
|
|
4216
4859
|
const memoriesEnabled = !process.argv.includes("--no-memories");
|
|
4217
|
-
const
|
|
4860
|
+
const http2 = process.argv.includes("--http");
|
|
4861
|
+
const host = flagValue("host") ?? "127.0.0.1";
|
|
4862
|
+
const fileAccess = localFileAccess({ http: http2, host });
|
|
4863
|
+
const serverOpts = { apiUrl, modelCacheDir, embeddingCacheDir, memoriesEnabled, fileAccess };
|
|
4218
4864
|
const services = createServices2(serverOpts);
|
|
4219
4865
|
const { server } = buildServer2({ ...serverOpts, services });
|
|
4220
|
-
if (
|
|
4866
|
+
if (http2) {
|
|
4221
4867
|
const token = await loadOrCreateToken(
|
|
4222
4868
|
flagValue("token-file") ?? defaultTokenPath(os2.homedir())
|
|
4223
4869
|
);
|
|
4224
4870
|
const portFlag = flagValue("port");
|
|
4225
|
-
const host = flagValue("host") ?? "127.0.0.1";
|
|
4226
4871
|
const running = await startHttpServer({
|
|
4227
4872
|
makeServer: () => buildServer2({ ...serverOpts, services }).server,
|
|
4228
4873
|
authorize: (bearer) => tokenMatches(bearer, token) ? { clientId: "cli" } : null,
|
|
@@ -4233,9 +4878,9 @@ async function main() {
|
|
|
4233
4878
|
`virlow-mcp: listening on http://${running.host}:${running.port}/mcp
|
|
4234
4879
|
`
|
|
4235
4880
|
);
|
|
4236
|
-
if (host
|
|
4881
|
+
if (!isLoopbackBind(host)) {
|
|
4237
4882
|
process.stderr.write(
|
|
4238
|
-
"virlow-mcp: WARNING \u2014 bound to a non-loopback address. Anyone who can reach this port and holds the token can read your decrypted notes.\nvirlow-mcp: WARNING \u2014 this listener speaks plain HTTP, not HTTPS. Off loopback, your notes and the bearer token travel unencrypted. Put a TLS-terminating tunnel in front of it (cloudflared, ssh -R, Tailscale) rather than exposing this port directly.\n"
|
|
4883
|
+
"virlow-mcp: WARNING \u2014 bound to a non-loopback address. Anyone who can reach this port and holds the token can read your decrypted notes.\nvirlow-mcp: WARNING \u2014 this listener speaks plain HTTP, not HTTPS. Off loopback, your notes and the bearer token travel unencrypted. Put a TLS-terminating tunnel in front of it (cloudflared, ssh -R, Tailscale) rather than exposing this port directly.\nvirlow-mcp: local file access is disabled for remote binding: the attachment tools cannot read or write files on this machine (no path or save_to).\n"
|
|
4239
4884
|
);
|
|
4240
4885
|
}
|
|
4241
4886
|
process.stderr.write(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "virlow-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.81.0",
|
|
4
4
|
"description": "Local MCP server for Virlow Secure Notes: end-to-end-encrypted AI memories and notes for Cursor, Claude, Codex, and any MCP client.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"esbuild": "^0.25.0",
|
|
35
35
|
"typescript": "^5.8.0",
|
|
36
36
|
"vitest": "^3.0.0",
|
|
37
|
-
"@batalabs/virlow-crypto": "3.
|
|
38
|
-
"@batalabs/virlow-mcp-core": "3.
|
|
39
|
-
"@batalabs/virlow-memory": "3.
|
|
37
|
+
"@batalabs/virlow-crypto": "3.81.0",
|
|
38
|
+
"@batalabs/virlow-mcp-core": "3.81.0",
|
|
39
|
+
"@batalabs/virlow-memory": "3.81.0"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "node build.mjs",
|