joplin-mcp-server 2.1.0 → 2.2.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/bin.d.ts +1 -1
- package/dist/bin.js +7 -7
- package/dist/bin.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +666 -442
- package/dist/index.js.map +1 -1
- package/package.json +15 -20
package/dist/index.js
CHANGED
|
@@ -3,21 +3,20 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import fs, { readFileSync } from "fs";
|
|
6
|
+
import { Either, Left, Match, Option, Right } from "functype";
|
|
6
7
|
import path, { dirname, join, resolve } from "path";
|
|
7
8
|
import { fileURLToPath } from "url";
|
|
8
9
|
import { exec, execFile, spawn } from "child_process";
|
|
9
10
|
import crypto$1 from "crypto";
|
|
10
|
-
import {
|
|
11
|
-
import os from "os";
|
|
11
|
+
import { Path, Platform } from "functype-os";
|
|
12
12
|
import { promisify } from "util";
|
|
13
13
|
import axios from "axios";
|
|
14
|
-
import { FastMCP } from "fastmcp";
|
|
14
|
+
import { FastMCP, UserError } from "fastmcp";
|
|
15
15
|
import { z } from "zod";
|
|
16
|
-
|
|
17
16
|
//#region src/lib/joplin-sidecar.ts
|
|
18
17
|
const execAsync = promisify(exec);
|
|
19
18
|
const execFileAsync = promisify(execFile);
|
|
20
|
-
const isWindows =
|
|
19
|
+
const isWindows = Platform.isWindows();
|
|
21
20
|
const whichCmd = isWindows ? "where" : "which";
|
|
22
21
|
const DEFAULT_API_PORT = 41184;
|
|
23
22
|
const MAX_PORT_ATTEMPTS = 10;
|
|
@@ -36,18 +35,64 @@ const fetchWithTimeout = async (url, timeoutMs = 5e3) => {
|
|
|
36
35
|
clearTimeout(timer);
|
|
37
36
|
}
|
|
38
37
|
};
|
|
38
|
+
const CLIPPER_PID_FILE = "clipper-pid.txt";
|
|
39
|
+
const readProfileServerPid = (profileDir) => {
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(join(profileDir, CLIPPER_PID_FILE), "utf-8").trim();
|
|
42
|
+
const pid = Number.parseInt(raw, 10);
|
|
43
|
+
return Number.isInteger(pid) && pid > 0 ? Option(pid) : Option.none();
|
|
44
|
+
} catch {
|
|
45
|
+
return Option.none();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const isProcessAlive = (pid) => {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const servesOurProfile = (profileDir) => readProfileServerPid(profileDir).fold(() => false, (pid) => isProcessAlive(pid));
|
|
57
|
+
const SIDECAR_STAMP_FILE = ".mcp-sidecar.json";
|
|
58
|
+
const computeIdentityHash = (config) => crypto$1.createHash("sha256").update(JSON.stringify({
|
|
59
|
+
version: config.version ?? "unknown",
|
|
60
|
+
syncTarget: config.syncTarget,
|
|
61
|
+
syncInterval: config.syncInterval
|
|
62
|
+
})).digest("hex").slice(0, 16);
|
|
63
|
+
const readSidecarStamp = (profileDir) => {
|
|
64
|
+
try {
|
|
65
|
+
const stamp = JSON.parse(fs.readFileSync(join(profileDir, SIDECAR_STAMP_FILE), "utf-8"));
|
|
66
|
+
if (typeof stamp.version !== "string" || typeof stamp.identity !== "string") return Option.none();
|
|
67
|
+
return Option({
|
|
68
|
+
version: stamp.version,
|
|
69
|
+
identity: stamp.identity
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
return Option.none();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const writeSidecarStamp = (profileDir, stamp) => {
|
|
76
|
+
try {
|
|
77
|
+
fs.writeFileSync(join(profileDir, SIDECAR_STAMP_FILE), JSON.stringify(stamp));
|
|
78
|
+
} catch {}
|
|
79
|
+
};
|
|
80
|
+
const inspectRunningSidecar = (profileDir, identity) => {
|
|
81
|
+
if (!servesOurProfile(profileDir)) return "not-ours";
|
|
82
|
+
return readSidecarStamp(profileDir).fold(() => "stale", (stamp) => stamp.identity === identity ? "reusable" : "stale");
|
|
83
|
+
};
|
|
39
84
|
const isConnectionRefused = (err) => {
|
|
40
85
|
var _e$cause, _e$cause2;
|
|
41
86
|
const e = err;
|
|
42
|
-
if ((
|
|
43
|
-
if (
|
|
87
|
+
if (((_e$cause = e.cause) === null || _e$cause === void 0 ? void 0 : _e$cause.code) === "ECONNREFUSED") return true;
|
|
88
|
+
if (((_e$cause2 = e.cause) === null || _e$cause2 === void 0 || (_e$cause2 = _e$cause2.errors) === null || _e$cause2 === void 0 ? void 0 : _e$cause2.some((inner) => inner.code === "ECONNREFUSED")) === true) return true;
|
|
44
89
|
return false;
|
|
45
90
|
};
|
|
46
|
-
const probePort = async (port, token) => {
|
|
91
|
+
const probePort = async (port, token, profileDir, identity) => {
|
|
47
92
|
try {
|
|
48
93
|
if (await (await fetchWithTimeout(`http://127.0.0.1:${port}/ping`, 3e3)).text() !== "JoplinClipperServer") return "occupied_other";
|
|
49
94
|
try {
|
|
50
|
-
if ((await fetchWithTimeout(`http://127.0.0.1:${port}/folders?token=${encodeURIComponent(token)}&limit=1`, 3e3)).ok) return "joplin_ours";
|
|
95
|
+
if ((await fetchWithTimeout(`http://127.0.0.1:${port}/folders?token=${encodeURIComponent(token)}&limit=1`, 3e3)).ok) return Match(inspectRunningSidecar(profileDir, identity)).case("reusable", () => "joplin_ours").case("stale", () => "joplin_stale").default(() => "joplin_unowned");
|
|
51
96
|
return "joplin_foreign";
|
|
52
97
|
} catch {
|
|
53
98
|
return "joplin_foreign";
|
|
@@ -57,10 +102,10 @@ const probePort = async (port, token) => {
|
|
|
57
102
|
return "occupied_unresponsive";
|
|
58
103
|
}
|
|
59
104
|
};
|
|
60
|
-
const resolveAvailablePort = async (startPort, token) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const status = await probePort(port, token);
|
|
105
|
+
const resolveAvailablePort = async (startPort, token, profileDir, identity) => {
|
|
106
|
+
const tryPort = async (port, desktopDetected) => {
|
|
107
|
+
if (port >= startPort + MAX_PORT_ATTEMPTS) return { outcome: "exhausted" };
|
|
108
|
+
const status = await probePort(port, token, profileDir, identity);
|
|
64
109
|
if (status === "free") return {
|
|
65
110
|
outcome: "free",
|
|
66
111
|
port,
|
|
@@ -71,21 +116,40 @@ const resolveAvailablePort = async (startPort, token) => {
|
|
|
71
116
|
port,
|
|
72
117
|
desktopDetected
|
|
73
118
|
};
|
|
119
|
+
if (status === "joplin_stale") return {
|
|
120
|
+
outcome: "replace_stale",
|
|
121
|
+
port,
|
|
122
|
+
desktopDetected
|
|
123
|
+
};
|
|
124
|
+
if (status === "joplin_unowned") {
|
|
125
|
+
process.stderr.write(`[joplin-sidecar] Port ${port}: Joplin server accepts our token but serves a different profile (likely an orphan from a previous run), skipping\n`);
|
|
126
|
+
return tryPort(port + 1, desktopDetected);
|
|
127
|
+
}
|
|
74
128
|
if (status === "joplin_foreign") {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
129
|
+
process.stderr.write(`[joplin-sidecar] Port ${port}: another Joplin instance with a different token (possibly Joplin Desktop), skipping\n`);
|
|
130
|
+
return tryPort(port + 1, true);
|
|
131
|
+
}
|
|
132
|
+
process.stderr.write(`[joplin-sidecar] Port ${port} occupied (${status}), trying next...\n`);
|
|
133
|
+
return tryPort(port + 1, desktopDetected);
|
|
134
|
+
};
|
|
135
|
+
return tryPort(startPort, false);
|
|
80
136
|
};
|
|
137
|
+
const CLI_BIN_NAME = isWindows ? "joplin.cmd" : "joplin";
|
|
138
|
+
const CLI_SEARCH_DEPTH = 5;
|
|
139
|
+
const ancestorDirs = (dir, remaining) => {
|
|
140
|
+
const parent = dirname(dir);
|
|
141
|
+
if (remaining <= 0 || parent === dir) return [dir];
|
|
142
|
+
return [dir, ...ancestorDirs(parent, remaining - 1)];
|
|
143
|
+
};
|
|
144
|
+
const localCliCandidates = () => [...ancestorDirs(dirname(fileURLToPath(import.meta.url)), CLI_SEARCH_DEPTH), process.cwd()].map((root) => join(root, "node_modules", ".bin", CLI_BIN_NAME));
|
|
81
145
|
const findJoplinCli = async () => {
|
|
82
146
|
const envCli = process.env.JOPLIN_CLI;
|
|
83
147
|
if (envCli) {
|
|
84
148
|
if (fs.existsSync(envCli)) return Right(envCli);
|
|
85
149
|
return Left(sidecarError("CLI_NOT_FOUND", `JOPLIN_CLI path not found: ${envCli}`));
|
|
86
150
|
}
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
151
|
+
const bundled = localCliCandidates().find((candidate) => fs.existsSync(candidate));
|
|
152
|
+
if (bundled) return Right(bundled);
|
|
89
153
|
try {
|
|
90
154
|
const { stdout } = await execAsync(`${whichCmd} joplin`, {
|
|
91
155
|
encoding: "utf-8",
|
|
@@ -139,20 +203,22 @@ const buildSettingsRecord = (config) => {
|
|
|
139
203
|
return settings;
|
|
140
204
|
};
|
|
141
205
|
const runJoplinConfig = async (cli, profileDir, key, value) => {
|
|
142
|
-
|
|
206
|
+
const cmd = cli.endsWith("npx") ? "npx" : cli;
|
|
207
|
+
const args = cli.endsWith("npx") ? [
|
|
143
208
|
"joplin",
|
|
144
|
-
"config",
|
|
145
209
|
"--profile",
|
|
146
210
|
profileDir,
|
|
211
|
+
"config",
|
|
147
212
|
key,
|
|
148
213
|
value
|
|
149
214
|
] : [
|
|
150
|
-
"config",
|
|
151
215
|
"--profile",
|
|
152
216
|
profileDir,
|
|
217
|
+
"config",
|
|
153
218
|
key,
|
|
154
219
|
value
|
|
155
|
-
]
|
|
220
|
+
];
|
|
221
|
+
await execFileAsync(cmd, args, {
|
|
156
222
|
encoding: "utf-8",
|
|
157
223
|
timeout: 3e4,
|
|
158
224
|
shell: isWindows
|
|
@@ -170,18 +236,17 @@ const configureJoplin = async (cli, config) => {
|
|
|
170
236
|
};
|
|
171
237
|
const spawnServer = (cli, config) => {
|
|
172
238
|
try {
|
|
173
|
-
var _proc$stderr, _proc$stdout;
|
|
174
239
|
const proc = spawn(cli.endsWith("npx") ? "npx" : cli, cli.endsWith("npx") ? [
|
|
175
240
|
"joplin",
|
|
176
|
-
"server",
|
|
177
|
-
"start",
|
|
178
241
|
"--profile",
|
|
179
|
-
config.profileDir
|
|
180
|
-
] : [
|
|
242
|
+
config.profileDir,
|
|
181
243
|
"server",
|
|
182
|
-
"start"
|
|
244
|
+
"start"
|
|
245
|
+
] : [
|
|
183
246
|
"--profile",
|
|
184
|
-
config.profileDir
|
|
247
|
+
config.profileDir,
|
|
248
|
+
"server",
|
|
249
|
+
"start"
|
|
185
250
|
], {
|
|
186
251
|
stdio: [
|
|
187
252
|
"ignore",
|
|
@@ -191,10 +256,10 @@ const spawnServer = (cli, config) => {
|
|
|
191
256
|
detached: false,
|
|
192
257
|
shell: isWindows
|
|
193
258
|
});
|
|
194
|
-
|
|
259
|
+
proc.stderr.on("data", (data) => {
|
|
195
260
|
process.stderr.write(`[joplin-sidecar] ${data.toString()}`);
|
|
196
261
|
});
|
|
197
|
-
|
|
262
|
+
proc.stdout.on("data", (data) => {
|
|
198
263
|
process.stderr.write(`[joplin-sidecar] ${data.toString()}`);
|
|
199
264
|
});
|
|
200
265
|
proc.on("error", (err) => {
|
|
@@ -207,13 +272,13 @@ const spawnServer = (cli, config) => {
|
|
|
207
272
|
};
|
|
208
273
|
const waitForReady = async (port, token, proc, maxRetries = 30, intervalMs = 1e3) => {
|
|
209
274
|
const deadline = Date.now() + 6e4;
|
|
210
|
-
|
|
275
|
+
const procExitState = { code: null };
|
|
211
276
|
if (proc) proc.once("exit", (code) => {
|
|
212
|
-
|
|
277
|
+
procExitState.code = code;
|
|
213
278
|
});
|
|
214
|
-
|
|
215
|
-
if (Date.now() > deadline)
|
|
216
|
-
if (
|
|
279
|
+
const attempt = async (i) => {
|
|
280
|
+
if (i >= maxRetries || Date.now() > deadline) return Left(sidecarError("HEALTH_CHECK_FAILED", "Joplin server did not become ready within 60s"));
|
|
281
|
+
if (procExitState.code !== null) return Left(sidecarError("SPAWN_FAILED", `Joplin server process exited unexpectedly (code ${procExitState.code}). Port ${port} may already be in use by another Joplin instance or process.`));
|
|
217
282
|
try {
|
|
218
283
|
if ((await fetchWithTimeout(`http://127.0.0.1:${port}/ping`)).ok) try {
|
|
219
284
|
const authResponse = await fetchWithTimeout(`http://127.0.0.1:${port}/folders?token=${encodeURIComponent(token)}&limit=1`);
|
|
@@ -227,8 +292,9 @@ const waitForReady = async (port, token, proc, maxRetries = 30, intervalMs = 1e3
|
|
|
227
292
|
}
|
|
228
293
|
} catch {}
|
|
229
294
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
230
|
-
|
|
231
|
-
|
|
295
|
+
return attempt(i + 1);
|
|
296
|
+
};
|
|
297
|
+
return attempt(0);
|
|
232
298
|
};
|
|
233
299
|
const CONFIG_CACHE_FILE = ".mcp-configured";
|
|
234
300
|
const computeConfigHash = (config) => {
|
|
@@ -263,17 +329,70 @@ var JoplinSidecar = class {
|
|
|
263
329
|
childProcess = null;
|
|
264
330
|
startPromise = null;
|
|
265
331
|
portResolution = null;
|
|
332
|
+
cleanupRegistered = false;
|
|
266
333
|
constructor(config) {
|
|
267
334
|
this.config = {
|
|
268
|
-
profileDir: config.profileDir ?? join(
|
|
269
|
-
apiPort: config.apiPort ??
|
|
335
|
+
profileDir: config.profileDir ?? join(Platform.homeDir(), ".config", "joplin-mcp"),
|
|
336
|
+
apiPort: config.apiPort ?? 41184,
|
|
270
337
|
apiToken: config.apiToken,
|
|
271
338
|
syncTarget: config.syncTarget,
|
|
272
|
-
syncInterval: config.syncInterval
|
|
339
|
+
syncInterval: config.syncInterval,
|
|
340
|
+
version: config.version
|
|
273
341
|
};
|
|
274
342
|
}
|
|
343
|
+
identity() {
|
|
344
|
+
return computeIdentityHash(this.config);
|
|
345
|
+
}
|
|
346
|
+
sidecarAlive() {
|
|
347
|
+
return servesOurProfile(this.config.profileDir);
|
|
348
|
+
}
|
|
349
|
+
async replaceStaleServer(port) {
|
|
350
|
+
const previous = readSidecarStamp(this.config.profileDir).fold(() => "unstamped", (s) => `v${s.version}`);
|
|
351
|
+
process.stderr.write(`[joplin-sidecar] Replacing sidecar on port ${port} (${previous} -> v${this.config.version ?? "unknown"} or changed sync config)\n`);
|
|
352
|
+
this.reapRecordedServer();
|
|
353
|
+
await this.waitForPortFree(port);
|
|
354
|
+
}
|
|
355
|
+
async waitForPortFree(port, maxAttempts = 20) {
|
|
356
|
+
const attempt = async (i) => {
|
|
357
|
+
if (i >= maxAttempts) {
|
|
358
|
+
process.stderr.write(`[joplin-sidecar] Port ${port} still busy after teardown, continuing anyway\n`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
await fetchWithTimeout(`http://127.0.0.1:${port}/ping`, 1e3);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
if (isConnectionRefused(err)) return;
|
|
365
|
+
}
|
|
366
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
367
|
+
return attempt(i + 1);
|
|
368
|
+
};
|
|
369
|
+
return attempt(0);
|
|
370
|
+
}
|
|
371
|
+
killChildSync() {
|
|
372
|
+
if (!this.childProcess) return;
|
|
373
|
+
try {
|
|
374
|
+
this.childProcess.kill("SIGTERM");
|
|
375
|
+
} catch {}
|
|
376
|
+
this.reapRecordedServer();
|
|
377
|
+
}
|
|
378
|
+
reapRecordedServer() {
|
|
379
|
+
readProfileServerPid(this.config.profileDir).fold(() => void 0, (pid) => {
|
|
380
|
+
if (pid !== process.pid && isProcessAlive(pid)) try {
|
|
381
|
+
process.kill(pid, "SIGTERM");
|
|
382
|
+
} catch {}
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
registerCleanup() {
|
|
386
|
+
if (this.cleanupRegistered) return;
|
|
387
|
+
this.cleanupRegistered = true;
|
|
388
|
+
process.once("exit", () => this.killChildSync());
|
|
389
|
+
process.once("SIGHUP", () => {
|
|
390
|
+
this.killChildSync();
|
|
391
|
+
process.exit(0);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
275
394
|
async resolvePort() {
|
|
276
|
-
const resolution = await resolveAvailablePort(this.config.apiPort, this.config.apiToken);
|
|
395
|
+
const resolution = await resolveAvailablePort(this.config.apiPort, this.config.apiToken, this.config.profileDir, this.identity());
|
|
277
396
|
this.portResolution = resolution;
|
|
278
397
|
if (resolution.outcome === "exhausted") {
|
|
279
398
|
const lastPort = this.config.apiPort + MAX_PORT_ATTEMPTS - 1;
|
|
@@ -288,12 +407,18 @@ var JoplinSidecar = class {
|
|
|
288
407
|
return ((_this$portResolution = this.portResolution) === null || _this$portResolution === void 0 ? void 0 : _this$portResolution.outcome) !== "exhausted" && (((_this$portResolution2 = this.portResolution) === null || _this$portResolution2 === void 0 ? void 0 : _this$portResolution2.desktopDetected) ?? false);
|
|
289
408
|
}
|
|
290
409
|
async start() {
|
|
410
|
+
if (this.startPromise && !this.sidecarAlive()) {
|
|
411
|
+
process.stderr.write("[joplin-sidecar] Sidecar is no longer running, restarting\n");
|
|
412
|
+
this.startPromise = null;
|
|
413
|
+
this.childProcess = null;
|
|
414
|
+
this.portResolution = null;
|
|
415
|
+
}
|
|
291
416
|
if (this.startPromise) return this.startPromise;
|
|
292
417
|
this.startPromise = this.doStart();
|
|
293
418
|
return this.startPromise;
|
|
294
419
|
}
|
|
295
420
|
async doStart() {
|
|
296
|
-
var _this$portResolution3;
|
|
421
|
+
var _this$portResolution3, _this$portResolution4;
|
|
297
422
|
const cliResult = await findJoplinCli();
|
|
298
423
|
if (Either.isLeft(cliResult)) return Left(cliResult.fold((e) => e, () => null));
|
|
299
424
|
const cli = cliResult.fold(() => "", (v) => v);
|
|
@@ -303,9 +428,10 @@ var JoplinSidecar = class {
|
|
|
303
428
|
if (Either.isLeft(portResult)) return Left(portResult.fold((e) => e, () => null));
|
|
304
429
|
}
|
|
305
430
|
if (((_this$portResolution3 = this.portResolution) === null || _this$portResolution3 === void 0 ? void 0 : _this$portResolution3.outcome) === "reuse_existing") {
|
|
306
|
-
process.stderr.write(`[joplin-sidecar] Existing Joplin server
|
|
431
|
+
process.stderr.write(`[joplin-sidecar] Existing Joplin server for this profile on port ${this.config.apiPort}, reusing\n`);
|
|
307
432
|
return Right(this.childProcess ?? null);
|
|
308
433
|
}
|
|
434
|
+
if (((_this$portResolution4 = this.portResolution) === null || _this$portResolution4 === void 0 ? void 0 : _this$portResolution4.outcome) === "replace_stale") await this.replaceStaleServer(this.config.apiPort);
|
|
309
435
|
const configHash = computeConfigHash(this.config);
|
|
310
436
|
if (isConfigCached(this.config.profileDir, configHash)) process.stderr.write("[joplin-sidecar] Configuration cached, skipping config step\n");
|
|
311
437
|
else {
|
|
@@ -319,10 +445,15 @@ var JoplinSidecar = class {
|
|
|
319
445
|
if (Either.isLeft(spawnResult)) return Left(spawnResult.fold((e) => e, () => null));
|
|
320
446
|
const proc = spawnResult.fold(() => null, (v) => v);
|
|
321
447
|
this.childProcess = proc;
|
|
448
|
+
this.registerCleanup();
|
|
322
449
|
process.stderr.write(`[joplin-sidecar] Server spawned (pid: ${proc.pid})\n`);
|
|
323
450
|
} else process.stderr.write(`[joplin-sidecar] Server already spawned (pid: ${this.childProcess.pid}), waiting for ready\n`);
|
|
324
451
|
const readyResult = await waitForReady(this.config.apiPort, this.config.apiToken, this.childProcess);
|
|
325
452
|
if (Either.isLeft(readyResult)) return Left(readyResult.fold((e) => e, () => null));
|
|
453
|
+
writeSidecarStamp(this.config.profileDir, {
|
|
454
|
+
version: this.config.version ?? "unknown",
|
|
455
|
+
identity: this.identity()
|
|
456
|
+
});
|
|
326
457
|
return Right(this.childProcess);
|
|
327
458
|
}
|
|
328
459
|
async stop() {
|
|
@@ -341,6 +472,7 @@ var JoplinSidecar = class {
|
|
|
341
472
|
resolve();
|
|
342
473
|
});
|
|
343
474
|
});
|
|
475
|
+
this.reapRecordedServer();
|
|
344
476
|
this.childProcess = null;
|
|
345
477
|
process.stderr.write("[joplin-sidecar] Server stopped\n");
|
|
346
478
|
return Right(true);
|
|
@@ -364,17 +496,9 @@ var JoplinSidecar = class {
|
|
|
364
496
|
return "127.0.0.1";
|
|
365
497
|
}
|
|
366
498
|
};
|
|
367
|
-
|
|
368
499
|
//#endregion
|
|
369
500
|
//#region src/lib/parse-args.ts
|
|
370
501
|
const expandVars = (p) => p.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "").replace(/\$(\w+)/g, (_, name) => process.env[name] ?? "");
|
|
371
|
-
const isWsl = (() => {
|
|
372
|
-
try {
|
|
373
|
-
return fs.readFileSync("/proc/version", "utf-8").toLowerCase().includes("microsoft");
|
|
374
|
-
} catch {
|
|
375
|
-
return false;
|
|
376
|
-
}
|
|
377
|
-
})();
|
|
378
502
|
const isNonEmptyDir = (p) => {
|
|
379
503
|
try {
|
|
380
504
|
return fs.readdirSync(p).length > 0;
|
|
@@ -383,30 +507,21 @@ const isNonEmptyDir = (p) => {
|
|
|
383
507
|
}
|
|
384
508
|
};
|
|
385
509
|
const resolveWslPath = (linuxPath, relativeToHome) => {
|
|
386
|
-
if (!
|
|
510
|
+
if (!Platform.isWSL()) return linuxPath;
|
|
387
511
|
if (fs.existsSync(linuxPath) && isNonEmptyDir(linuxPath)) return linuxPath;
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
"Default",
|
|
393
|
-
"Default User",
|
|
394
|
-
"All Users"
|
|
395
|
-
].includes(u));
|
|
396
|
-
for (const user of users) {
|
|
397
|
-
const winPath = join(usersDir, user, relativeToHome);
|
|
398
|
-
if (isNonEmptyDir(winPath)) {
|
|
399
|
-
process.stderr.write(`[wsl] Path ${linuxPath} empty/missing, using Windows path: ${winPath}\n`);
|
|
400
|
-
return winPath;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
} catch {}
|
|
404
|
-
return linuxPath;
|
|
512
|
+
return Platform.windowsHomeDir().map((winHome) => join(winHome, relativeToHome)).filter(isNonEmptyDir).map((winPath) => {
|
|
513
|
+
process.stderr.write(`[wsl] Path ${linuxPath} empty/missing, using Windows path: ${winPath}\n`);
|
|
514
|
+
return winPath;
|
|
515
|
+
}).orElse(linuxPath);
|
|
405
516
|
};
|
|
406
517
|
const expandPath = (p) => {
|
|
407
518
|
const expanded = expandVars(p);
|
|
408
|
-
|
|
409
|
-
|
|
519
|
+
const tildeExpanded = Path.expandTilde(expanded);
|
|
520
|
+
if (expanded === "~" || expanded.startsWith("~/")) {
|
|
521
|
+
const relativeToHome = expanded === "~" ? "" : expanded.slice(2);
|
|
522
|
+
return resolveWslPath(tildeExpanded, relativeToHome);
|
|
523
|
+
}
|
|
524
|
+
return tildeExpanded;
|
|
410
525
|
};
|
|
411
526
|
const extractArg = (args, flag) => {
|
|
412
527
|
const index = args.indexOf(flag);
|
|
@@ -461,8 +576,6 @@ const buildSyncTarget = (args) => {
|
|
|
461
576
|
};
|
|
462
577
|
function parseArgs() {
|
|
463
578
|
const args = process.argv.slice(2);
|
|
464
|
-
let transport = "stdio";
|
|
465
|
-
let httpPort = 3e3;
|
|
466
579
|
const loadEnvFile = (envPath) => {
|
|
467
580
|
try {
|
|
468
581
|
if (fs.existsSync(envPath)) {
|
|
@@ -489,30 +602,32 @@ function parseArgs() {
|
|
|
489
602
|
}
|
|
490
603
|
};
|
|
491
604
|
extractArg(args, "--env-file").fold(() => loadEnvFile(".env"), (file) => loadEnvFile(resolve(process.cwd(), file)));
|
|
492
|
-
extractArg(args, "--token")
|
|
493
|
-
|
|
494
|
-
});
|
|
495
|
-
extractArg(args, "--transport").fold(() => {}, (value) => {
|
|
605
|
+
const explicitToken = extractArg(args, "--token");
|
|
606
|
+
const transport = extractArg(args, "--transport").map((value) => {
|
|
496
607
|
if (value !== "stdio" && value !== "http") {
|
|
497
608
|
process.stderr.write("Error: --transport must be either 'stdio' or 'http'\n");
|
|
498
609
|
process.exit(1);
|
|
499
610
|
}
|
|
500
|
-
|
|
501
|
-
});
|
|
502
|
-
extractArg(args, "--http-port").
|
|
611
|
+
return value;
|
|
612
|
+
}).orElse("stdio");
|
|
613
|
+
const httpPort = extractArg(args, "--http-port").map((value) => {
|
|
503
614
|
const parsed = parseInt(value, 10);
|
|
504
615
|
if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
|
|
505
616
|
process.stderr.write("Error: --http-port must be a valid port number (1-65535)\n");
|
|
506
617
|
process.exit(1);
|
|
507
618
|
}
|
|
508
|
-
|
|
509
|
-
});
|
|
510
|
-
const profileDir = extractArg(args, "--profile").or(Option(process.env.JOPLIN_PROFILE)).map(expandPath).orElse(
|
|
619
|
+
return parsed;
|
|
620
|
+
}).orElse(3e3);
|
|
621
|
+
const profileDir = extractArg(args, "--profile").or(Option(process.env.JOPLIN_PROFILE)).map(expandPath).orElse(expandPath("~/.config/joplin-mcp"));
|
|
622
|
+
const syncTarget = extractArg(args, "--sync-target").or(Option(process.env.JOPLIN_SYNC_TARGET));
|
|
623
|
+
const syncPath = extractArg(args, "--sync-path").or(Option(process.env.JOPLIN_SYNC_PATH)).map(expandPath);
|
|
624
|
+
const syncUsername = extractArg(args, "--sync-username").or(Option(process.env.JOPLIN_SYNC_USERNAME));
|
|
625
|
+
const syncPassword = extractArg(args, "--sync-password").or(Option(process.env.JOPLIN_SYNC_PASSWORD));
|
|
511
626
|
const syncResult = buildSyncTarget({
|
|
512
|
-
syncTarget
|
|
513
|
-
syncPath
|
|
514
|
-
syncUsername
|
|
515
|
-
syncPassword
|
|
627
|
+
syncTarget,
|
|
628
|
+
syncPath,
|
|
629
|
+
syncUsername,
|
|
630
|
+
syncPassword
|
|
516
631
|
});
|
|
517
632
|
if (Either.isLeft(syncResult)) {
|
|
518
633
|
const err = syncResult.fold((e) => e, () => "");
|
|
@@ -542,7 +657,8 @@ OPTIONS:
|
|
|
542
657
|
--help, -h Show this help message
|
|
543
658
|
|
|
544
659
|
ENVIRONMENT VARIABLES:
|
|
545
|
-
JOPLIN_TOKEN
|
|
660
|
+
JOPLIN_TOKEN API token for external mode (JOPLIN_HOST/JOPLIN_PORT).
|
|
661
|
+
Ignored in sidecar mode, which manages its own token.
|
|
546
662
|
JOPLIN_HOST Connect to existing Joplin at this host (skips sidecar)
|
|
547
663
|
JOPLIN_PORT Connect to existing Joplin on this port (skips sidecar)
|
|
548
664
|
JOPLIN_CLI Path to joplin CLI binary (overrides auto-detection)
|
|
@@ -597,113 +713,52 @@ Find your Joplin token in: Tools > Options > Web Clipper
|
|
|
597
713
|
transport,
|
|
598
714
|
httpPort,
|
|
599
715
|
profileDir,
|
|
600
|
-
syncTarget: resolvedSyncTarget
|
|
716
|
+
syncTarget: resolvedSyncTarget,
|
|
717
|
+
explicitToken
|
|
601
718
|
};
|
|
602
719
|
}
|
|
603
|
-
|
|
604
720
|
//#endregion
|
|
605
|
-
//#region src/lib/
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
async getAllItems(path, options = {}) {
|
|
623
|
-
let page = 1;
|
|
624
|
-
const items = [];
|
|
625
|
-
try {
|
|
626
|
-
while (true) {
|
|
627
|
-
const response = (await this.get(path, this.mergeRequestOptions(options, { query: { page } }))).fold((err) => {
|
|
628
|
-
throw err;
|
|
629
|
-
}, (data) => data);
|
|
630
|
-
if (!response || typeof response !== "object" || !Array.isArray(response.items)) return Left(/* @__PURE__ */ new Error(`Unexpected response format from Joplin API for path: ${path}`));
|
|
631
|
-
items.push(...response.items);
|
|
632
|
-
page += 1;
|
|
633
|
-
if (!response.has_more) break;
|
|
634
|
-
}
|
|
635
|
-
return Right(items);
|
|
636
|
-
} catch (error) {
|
|
637
|
-
process.stderr.write(`Error in getAllItems for path ${path}: ${error}\n`);
|
|
638
|
-
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
async get(path, options = {}) {
|
|
642
|
-
try {
|
|
643
|
-
const { data } = await axios.get(`${this.baseURL}${path}`, {
|
|
644
|
-
params: this.requestOptions(options).query,
|
|
645
|
-
timeout: 3e4
|
|
646
|
-
});
|
|
647
|
-
return Right(data);
|
|
648
|
-
} catch (error) {
|
|
649
|
-
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
async post(path, body, options = {}) {
|
|
653
|
-
try {
|
|
654
|
-
const { data } = await axios.post(`${this.baseURL}${path}`, body, {
|
|
655
|
-
params: this.requestOptions(options).query,
|
|
656
|
-
timeout: 3e4
|
|
657
|
-
});
|
|
658
|
-
return Right(data);
|
|
659
|
-
} catch (error) {
|
|
660
|
-
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
async delete(path, options = {}) {
|
|
664
|
-
try {
|
|
665
|
-
const { data } = await axios.delete(`${this.baseURL}${path}`, {
|
|
666
|
-
params: this.requestOptions(options).query,
|
|
667
|
-
timeout: 3e4
|
|
668
|
-
});
|
|
669
|
-
return Right(data);
|
|
670
|
-
} catch (error) {
|
|
671
|
-
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
async put(path, body, options = {}) {
|
|
675
|
-
try {
|
|
676
|
-
const { data } = await axios.put(`${this.baseURL}${path}`, body, {
|
|
677
|
-
params: this.requestOptions(options).query,
|
|
678
|
-
timeout: 3e4
|
|
679
|
-
});
|
|
680
|
-
return Right(data);
|
|
681
|
-
} catch (error) {
|
|
682
|
-
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
requestOptions(options = {}) {
|
|
686
|
-
return this.mergeRequestOptions({ query: { token: this.token } }, options);
|
|
687
|
-
}
|
|
688
|
-
mergeRequestOptions(options1, options2) {
|
|
689
|
-
return {
|
|
690
|
-
query: {
|
|
691
|
-
...options1.query || {},
|
|
692
|
-
...options2.query || {}
|
|
693
|
-
},
|
|
694
|
-
...this.except(options1, "query"),
|
|
695
|
-
...this.except(options2, "query")
|
|
696
|
-
};
|
|
697
|
-
}
|
|
698
|
-
except(obj, key) {
|
|
699
|
-
const result = { ...obj };
|
|
700
|
-
delete result[key];
|
|
701
|
-
return result;
|
|
702
|
-
}
|
|
721
|
+
//#region src/lib/resolve-token.ts
|
|
722
|
+
const resolveTokenSource = (input) => {
|
|
723
|
+
if (input.explicitToken.isEmpty === false) return {
|
|
724
|
+
source: "explicit",
|
|
725
|
+
ambientIgnored: false
|
|
726
|
+
};
|
|
727
|
+
if (input.externalMode) return {
|
|
728
|
+
source: input.ambientToken.isEmpty === false ? "external-env" : "external-generated",
|
|
729
|
+
ambientIgnored: false
|
|
730
|
+
};
|
|
731
|
+
return {
|
|
732
|
+
source: "profile",
|
|
733
|
+
ambientIgnored: input.ambientToken.isEmpty === false
|
|
734
|
+
};
|
|
703
735
|
};
|
|
704
|
-
|
|
736
|
+
const externalTokenMissing = (input) => input.externalMode && input.explicitToken.isEmpty && input.ambientToken.isEmpty;
|
|
705
737
|
//#endregion
|
|
706
738
|
//#region src/lib/tools/base-tool.ts
|
|
739
|
+
/**
|
|
740
|
+
* Thrown by tools to signal an operation failure that should surface to the
|
|
741
|
+
* caller (and the LLM agent) as an MCP error response (isError: true), rather
|
|
742
|
+
* than being silently returned as a "successful" text result.
|
|
743
|
+
*/
|
|
744
|
+
var ToolError = class extends Error {
|
|
745
|
+
constructor(message) {
|
|
746
|
+
super(message);
|
|
747
|
+
this.name = "ToolError";
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
/**
|
|
751
|
+
* Extract a Joplin-style error message from an API response body. Joplin can
|
|
752
|
+
* return HTTP 200 with `{ error: "..." }` (or `{ error: ..., code: ... }`)
|
|
753
|
+
* when an operation fails — without this check the tool would treat the
|
|
754
|
+
* malformed payload as a success.
|
|
755
|
+
*/
|
|
756
|
+
const extractJoplinErrorMessage = (data) => {
|
|
757
|
+
if (data !== null && typeof data === "object" && "error" in data) {
|
|
758
|
+
const err = data.error;
|
|
759
|
+
if (typeof err === "string" && err.length > 0) return err;
|
|
760
|
+
}
|
|
761
|
+
};
|
|
707
762
|
var BaseTool = class {
|
|
708
763
|
apiClient;
|
|
709
764
|
constructor(apiClient) {
|
|
@@ -711,7 +766,8 @@ var BaseTool = class {
|
|
|
711
766
|
}
|
|
712
767
|
formatError(error, context) {
|
|
713
768
|
process.stderr.write(`${context} error: ${error}\n`);
|
|
714
|
-
|
|
769
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
770
|
+
return `Error ${context.toLowerCase()}: ${message}`;
|
|
715
771
|
}
|
|
716
772
|
validateId(id, type) {
|
|
717
773
|
if (!id) return `Please provide a ${type} ID. Example: ${type === "note" ? "read_note" : "read_notebook"} ${type}_id="your-${type}-id"`;
|
|
@@ -730,7 +786,6 @@ var BaseTool = class {
|
|
|
730
786
|
return new Date(timestamp).toLocaleString();
|
|
731
787
|
}
|
|
732
788
|
};
|
|
733
|
-
|
|
734
789
|
//#endregion
|
|
735
790
|
//#region src/lib/tools/create-folder.ts
|
|
736
791
|
var CreateFolder = class extends BaseTool {
|
|
@@ -742,14 +797,19 @@ var CreateFolder = class extends BaseTool {
|
|
|
742
797
|
const requestBody = { title: options.title.trim() };
|
|
743
798
|
if (options.parent_id) requestBody.parent_id = options.parent_id;
|
|
744
799
|
const createdFolder = this.unwrap(await this.apiClient.post("/folders", requestBody));
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
if (createdFolder.
|
|
748
|
-
|
|
749
|
-
if (
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
800
|
+
const joplinError = extractJoplinErrorMessage(createdFolder);
|
|
801
|
+
if (joplinError !== void 0) throw new ToolError(`Failed to create notebook: ${joplinError}`);
|
|
802
|
+
if (!createdFolder || typeof createdFolder !== "object" || !createdFolder.id) throw new ToolError(`Failed to create notebook: Joplin API returned an unexpected response (no folder id). Raw response: ${JSON.stringify(createdFolder)}`);
|
|
803
|
+
const parentInfo = await (async () => {
|
|
804
|
+
if (!createdFolder.parent_id) return "Top level";
|
|
805
|
+
try {
|
|
806
|
+
const parentNotebook = this.unwrap(await this.apiClient.get(`/folders/${createdFolder.parent_id}`, { query: { fields: "id,title" } }));
|
|
807
|
+
if (parentNotebook === null || parentNotebook === void 0 ? void 0 : parentNotebook.title) return `Inside "${parentNotebook.title}" (notebook_id: "${createdFolder.parent_id}")`;
|
|
808
|
+
return `Parent notebook ID: ${createdFolder.parent_id}`;
|
|
809
|
+
} catch {
|
|
810
|
+
return `Parent notebook ID: ${createdFolder.parent_id}`;
|
|
811
|
+
}
|
|
812
|
+
})();
|
|
753
813
|
const resultLines = [];
|
|
754
814
|
resultLines.push(`✅ Successfully created notebook!`);
|
|
755
815
|
resultLines.push("");
|
|
@@ -766,19 +826,21 @@ var CreateFolder = class extends BaseTool {
|
|
|
766
826
|
resultLines.push(` - View all notebooks: list_notebooks`);
|
|
767
827
|
return resultLines.join("\n");
|
|
768
828
|
} catch (error) {
|
|
769
|
-
if (error
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
829
|
+
if (error instanceof ToolError) throw error;
|
|
830
|
+
const err = error;
|
|
831
|
+
process.stderr.write(`creating notebook error: ${error}\n`);
|
|
832
|
+
if (err.response) {
|
|
833
|
+
if (err.response.status === 400) {
|
|
834
|
+
var _err$response$data;
|
|
835
|
+
throw new ToolError(`Failed to create notebook: Invalid request data. ${((_err$response$data = err.response.data) === null || _err$response$data === void 0 ? void 0 : _err$response$data.error) ?? "Please check your input parameters."}`);
|
|
773
836
|
}
|
|
774
|
-
if (
|
|
775
|
-
if (
|
|
837
|
+
if (err.response.status === 404 && options.parent_id !== void 0) throw new ToolError(`Failed to create notebook: Parent notebook with ID "${options.parent_id}" not found. Use list_notebooks to see available notebooks and their IDs, or omit parent_id to create a top-level notebook.`);
|
|
838
|
+
if (err.response.status === 409) throw new ToolError(`Failed to create notebook: A notebook with the title "${options.title}" might already exist in this location. Try a different title or check existing notebooks with list_notebooks.`);
|
|
776
839
|
}
|
|
777
|
-
|
|
840
|
+
throw new ToolError(`Failed to create notebook: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
778
841
|
}
|
|
779
842
|
}
|
|
780
843
|
};
|
|
781
|
-
|
|
782
844
|
//#endregion
|
|
783
845
|
//#region src/lib/tools/create-note.ts
|
|
784
846
|
var CreateNote = class extends BaseTool {
|
|
@@ -795,14 +857,19 @@ var CreateNote = class extends BaseTool {
|
|
|
795
857
|
if (options.is_todo !== void 0) requestBody.is_todo = options.is_todo;
|
|
796
858
|
if (options.image_data_url) requestBody.image_data_url = options.image_data_url;
|
|
797
859
|
const createdNote = this.unwrap(await this.apiClient.post("/notes", requestBody));
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
if (createdNote.
|
|
801
|
-
|
|
802
|
-
if (
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
860
|
+
const joplinError = extractJoplinErrorMessage(createdNote);
|
|
861
|
+
if (joplinError !== void 0) throw new ToolError(`Failed to create note: ${joplinError}`);
|
|
862
|
+
if (!createdNote || typeof createdNote !== "object" || !createdNote.id) throw new ToolError(`Failed to create note: Joplin API returned an unexpected response (no note id). Raw response: ${JSON.stringify(createdNote)}`);
|
|
863
|
+
const notebookInfo = await (async () => {
|
|
864
|
+
if (!createdNote.parent_id) return "Root level";
|
|
865
|
+
try {
|
|
866
|
+
const notebook = this.unwrap(await this.apiClient.get(`/folders/${createdNote.parent_id}`, { query: { fields: "id,title" } }));
|
|
867
|
+
if (notebook === null || notebook === void 0 ? void 0 : notebook.title) return `"${notebook.title}" (notebook_id: "${createdNote.parent_id}")`;
|
|
868
|
+
return `Notebook ID: ${createdNote.parent_id}`;
|
|
869
|
+
} catch {
|
|
870
|
+
return `Notebook ID: ${createdNote.parent_id}`;
|
|
871
|
+
}
|
|
872
|
+
})();
|
|
806
873
|
const resultLines = [];
|
|
807
874
|
resultLines.push(`✅ Successfully created note!`);
|
|
808
875
|
resultLines.push("");
|
|
@@ -820,18 +887,20 @@ var CreateNote = class extends BaseTool {
|
|
|
820
887
|
resultLines.push(` - Search for it: search_notes query="${createdNote.title}"`);
|
|
821
888
|
return resultLines.join("\n");
|
|
822
889
|
} catch (error) {
|
|
823
|
-
if (error
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
890
|
+
if (error instanceof ToolError) throw error;
|
|
891
|
+
const err = error;
|
|
892
|
+
process.stderr.write(`creating note error: ${error}\n`);
|
|
893
|
+
if (err.response) {
|
|
894
|
+
if (err.response.status === 400) {
|
|
895
|
+
var _err$response$data;
|
|
896
|
+
throw new ToolError(`Failed to create note: Invalid request data. ${((_err$response$data = err.response.data) === null || _err$response$data === void 0 ? void 0 : _err$response$data.error) ?? "Please check your input parameters."}`);
|
|
827
897
|
}
|
|
828
|
-
if (
|
|
898
|
+
if (err.response.status === 404 && options.parent_id !== void 0) throw new ToolError(`Failed to create note: Notebook with ID "${options.parent_id}" not found. Use list_notebooks to see available notebooks and their IDs.`);
|
|
829
899
|
}
|
|
830
|
-
|
|
900
|
+
throw new ToolError(`Failed to create note: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
831
901
|
}
|
|
832
902
|
}
|
|
833
903
|
};
|
|
834
|
-
|
|
835
904
|
//#endregion
|
|
836
905
|
//#region src/lib/tools/delete-folder.ts
|
|
837
906
|
var DeleteFolder = class extends BaseTool {
|
|
@@ -842,15 +911,13 @@ var DeleteFolder = class extends BaseTool {
|
|
|
842
911
|
if (folderIdError) return folderIdError.replace("notebook ID", "folder ID").replace("notebook_id", "folder_id");
|
|
843
912
|
if (!options.confirm) return `⚠️ This will permanently delete the notebook/folder!\n\nTo confirm deletion, use:\ndelete_folder {"folder_id": "${options.folder_id}", "confirm": true}\n\n⚠️ This action cannot be undone!`;
|
|
844
913
|
try {
|
|
845
|
-
var _notes$items, _subfolders$items;
|
|
846
914
|
const folderToDelete = this.unwrap(await this.apiClient.get(`/folders/${options.folder_id}`, { query: { fields: "id,title,parent_id" } }));
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
const
|
|
853
|
-
const subfolderCount = ((_subfolders$items = subfolders.items) === null || _subfolders$items === void 0 ? void 0 : _subfolders$items.length) || 0;
|
|
915
|
+
const folderLoadError = extractJoplinErrorMessage(folderToDelete);
|
|
916
|
+
if (folderLoadError !== void 0) throw new ToolError(`Failed to load folder "${options.folder_id}": ${folderLoadError}`);
|
|
917
|
+
if (!(folderToDelete === null || folderToDelete === void 0 ? void 0 : folderToDelete.id)) throw new ToolError(`Folder with ID "${options.folder_id}" not found. Use list_notebooks to see available folders and their IDs.`);
|
|
918
|
+
const [notes, subfolders] = await Promise.all([this.apiClient.get(`/folders/${options.folder_id}/notes`, { query: { fields: "id,title" } }).then((result) => result.fold(() => ({ items: [] }), (data) => data)), this.apiClient.get("/folders", { query: { fields: "id,title,parent_id" } }).then((result) => result.fold(() => ({ items: [] }), (response) => ({ items: response.items.filter((folder) => folder.parent_id === options.folder_id) })))]);
|
|
919
|
+
const noteCount = notes.items.length;
|
|
920
|
+
const subfolderCount = subfolders.items.length;
|
|
854
921
|
const totalContent = noteCount + subfolderCount;
|
|
855
922
|
if (totalContent > 0 && !options.force) {
|
|
856
923
|
const resultLines = [];
|
|
@@ -862,7 +929,7 @@ var DeleteFolder = class extends BaseTool {
|
|
|
862
929
|
resultLines.push("");
|
|
863
930
|
resultLines.push(`📝 Contains ${noteCount} notes:`);
|
|
864
931
|
notes.items.slice(0, 5).forEach((note) => {
|
|
865
|
-
resultLines.push(` - ${note.title
|
|
932
|
+
resultLines.push(` - ${note.title ?? "Untitled"}`);
|
|
866
933
|
});
|
|
867
934
|
if (noteCount > 5) resultLines.push(` ... and ${noteCount - 5} more notes`);
|
|
868
935
|
}
|
|
@@ -870,7 +937,7 @@ var DeleteFolder = class extends BaseTool {
|
|
|
870
937
|
resultLines.push("");
|
|
871
938
|
resultLines.push(`📁 Contains ${subfolderCount} subfolders:`);
|
|
872
939
|
subfolders.items.slice(0, 5).forEach((folder) => {
|
|
873
|
-
resultLines.push(` - ${folder.title}`);
|
|
940
|
+
resultLines.push(` - ${folder.title ?? "Untitled"}`);
|
|
874
941
|
});
|
|
875
942
|
if (subfolderCount > 5) resultLines.push(` ... and ${subfolderCount - 5} more folders`);
|
|
876
943
|
}
|
|
@@ -883,14 +950,18 @@ var DeleteFolder = class extends BaseTool {
|
|
|
883
950
|
resultLines.push(`⚠️ Force delete will permanently delete ALL ${totalContent} items inside!`);
|
|
884
951
|
return resultLines.join("\n");
|
|
885
952
|
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
953
|
+
const parentInfo = await (async () => {
|
|
954
|
+
if (!folderToDelete.parent_id) return "Top level";
|
|
955
|
+
try {
|
|
956
|
+
const parentFolder = this.unwrap(await this.apiClient.get(`/folders/${folderToDelete.parent_id}`, { query: { fields: "title" } }));
|
|
957
|
+
if (parentFolder === null || parentFolder === void 0 ? void 0 : parentFolder.title) return `Inside "${parentFolder.title}" (notebook_id: "${folderToDelete.parent_id}")`;
|
|
958
|
+
return `Parent ID: ${folderToDelete.parent_id}`;
|
|
959
|
+
} catch {
|
|
960
|
+
return `Parent ID: ${folderToDelete.parent_id}`;
|
|
961
|
+
}
|
|
962
|
+
})();
|
|
963
|
+
const deleteError = extractJoplinErrorMessage(this.unwrap(await this.apiClient.delete(`/folders/${options.folder_id}`)));
|
|
964
|
+
if (deleteError !== void 0) throw new ToolError(`Failed to delete folder: ${deleteError}`);
|
|
894
965
|
const resultLines = [];
|
|
895
966
|
resultLines.push(`🗑️ Successfully deleted notebook!`);
|
|
896
967
|
resultLines.push("");
|
|
@@ -913,16 +984,18 @@ var DeleteFolder = class extends BaseTool {
|
|
|
913
984
|
}
|
|
914
985
|
return resultLines.join("\n");
|
|
915
986
|
} catch (error) {
|
|
916
|
-
if (error
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
987
|
+
if (error instanceof ToolError) throw error;
|
|
988
|
+
const err = error;
|
|
989
|
+
process.stderr.write(`deleting folder error: ${error}\n`);
|
|
990
|
+
if (err.response) {
|
|
991
|
+
if (err.response.status === 404) throw new ToolError(`Failed to delete folder: Folder with ID "${options.folder_id}" not found. Use list_notebooks to see available folders and their IDs.`);
|
|
992
|
+
if (err.response.status === 403) throw new ToolError(`Failed to delete folder: Permission denied for folder with ID "${options.folder_id}". This might be a protected system folder.`);
|
|
993
|
+
if (err.response.status === 409) throw new ToolError(`Failed to delete folder: It may contain items that prevent deletion. Try moving or deleting the contents first, or use force option.`);
|
|
920
994
|
}
|
|
921
|
-
|
|
995
|
+
throw new ToolError(`Failed to delete folder: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
922
996
|
}
|
|
923
997
|
}
|
|
924
998
|
};
|
|
925
|
-
|
|
926
999
|
//#endregion
|
|
927
1000
|
//#region src/lib/tools/delete-note.ts
|
|
928
1001
|
var DeleteNote = class extends BaseTool {
|
|
@@ -934,15 +1007,21 @@ var DeleteNote = class extends BaseTool {
|
|
|
934
1007
|
if (!options.confirm) return `⚠️ This will permanently delete the note!\n\nTo confirm deletion, use:\ndelete_note {"note_id": "${options.note_id}", "confirm": true}\n\n⚠️ This action cannot be undone!`;
|
|
935
1008
|
try {
|
|
936
1009
|
const noteToDelete = this.unwrap(await this.apiClient.get(`/notes/${options.note_id}`, { query: { fields: "id,title,body,parent_id,is_todo,todo_completed,created_time,updated_time" } }));
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
if (noteToDelete.
|
|
940
|
-
|
|
941
|
-
if (
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1010
|
+
const noteLoadError = extractJoplinErrorMessage(noteToDelete);
|
|
1011
|
+
if (noteLoadError !== void 0) throw new ToolError(`Failed to load note "${options.note_id}": ${noteLoadError}`);
|
|
1012
|
+
if (!(noteToDelete === null || noteToDelete === void 0 ? void 0 : noteToDelete.id)) throw new ToolError(`Note with ID "${options.note_id}" not found. Use search_notes to find notes and their IDs.`);
|
|
1013
|
+
const notebookInfo = await (async () => {
|
|
1014
|
+
if (!noteToDelete.parent_id) return "Root level";
|
|
1015
|
+
try {
|
|
1016
|
+
const notebook = this.unwrap(await this.apiClient.get(`/folders/${noteToDelete.parent_id}`, { query: { fields: "title" } }));
|
|
1017
|
+
if (notebook === null || notebook === void 0 ? void 0 : notebook.title) return `"${notebook.title}" (notebook_id: "${noteToDelete.parent_id}")`;
|
|
1018
|
+
return `Notebook ID: ${noteToDelete.parent_id}`;
|
|
1019
|
+
} catch {
|
|
1020
|
+
return `Notebook ID: ${noteToDelete.parent_id}`;
|
|
1021
|
+
}
|
|
1022
|
+
})();
|
|
1023
|
+
const deleteError = extractJoplinErrorMessage(this.unwrap(await this.apiClient.delete(`/notes/${options.note_id}`)));
|
|
1024
|
+
if (deleteError !== void 0) throw new ToolError(`Failed to delete note: ${deleteError}`);
|
|
946
1025
|
const resultLines = [];
|
|
947
1026
|
resultLines.push(`🗑️ Successfully deleted note!`);
|
|
948
1027
|
resultLines.push("");
|
|
@@ -973,15 +1052,17 @@ var DeleteNote = class extends BaseTool {
|
|
|
973
1052
|
}
|
|
974
1053
|
return resultLines.join("\n");
|
|
975
1054
|
} catch (error) {
|
|
976
|
-
if (error
|
|
977
|
-
|
|
978
|
-
|
|
1055
|
+
if (error instanceof ToolError) throw error;
|
|
1056
|
+
const err = error;
|
|
1057
|
+
process.stderr.write(`deleting note error: ${error}\n`);
|
|
1058
|
+
if (err.response) {
|
|
1059
|
+
if (err.response.status === 404) throw new ToolError(`Failed to delete note: Note with ID "${options.note_id}" not found. Use search_notes to find notes and their IDs.`);
|
|
1060
|
+
if (err.response.status === 403) throw new ToolError(`Failed to delete note: Permission denied for note with ID "${options.note_id}". This might be a protected system note.`);
|
|
979
1061
|
}
|
|
980
|
-
|
|
1062
|
+
throw new ToolError(`Failed to delete note: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
981
1063
|
}
|
|
982
1064
|
}
|
|
983
1065
|
};
|
|
984
|
-
|
|
985
1066
|
//#endregion
|
|
986
1067
|
//#region src/lib/tools/edit-folder.ts
|
|
987
1068
|
var EditFolder = class extends BaseTool {
|
|
@@ -998,27 +1079,31 @@ var EditFolder = class extends BaseTool {
|
|
|
998
1079
|
}
|
|
999
1080
|
try {
|
|
1000
1081
|
const currentFolder = this.unwrap(await this.apiClient.get(`/folders/${options.folder_id}`, { query: { fields: "id,title,parent_id" } }));
|
|
1001
|
-
|
|
1082
|
+
const currentFolderError = extractJoplinErrorMessage(currentFolder);
|
|
1083
|
+
if (currentFolderError !== void 0) throw new ToolError(`Failed to load folder "${options.folder_id}": ${currentFolderError}`);
|
|
1084
|
+
if (!(currentFolder === null || currentFolder === void 0 ? void 0 : currentFolder.id)) throw new ToolError(`Folder with ID "${options.folder_id}" not found. Use list_notebooks to see available folders and their IDs.`);
|
|
1002
1085
|
const updateBody = {};
|
|
1003
1086
|
if (options.title !== void 0) updateBody.title = options.title.trim();
|
|
1004
1087
|
if (options.parent_id !== void 0) updateBody.parent_id = options.parent_id;
|
|
1005
1088
|
const updatedFolder = this.unwrap(await this.apiClient.put(`/folders/${options.folder_id}`, updateBody));
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1089
|
+
const joplinError = extractJoplinErrorMessage(updatedFolder);
|
|
1090
|
+
if (joplinError !== void 0) throw new ToolError(`Failed to update folder: ${joplinError}`);
|
|
1091
|
+
if (!updatedFolder || typeof updatedFolder !== "object" || !updatedFolder.id) throw new ToolError(`Failed to update folder: Joplin API returned an unexpected response (no folder id). Raw response: ${JSON.stringify(updatedFolder)}`);
|
|
1092
|
+
const fetchParentInfo = async (parentId) => {
|
|
1093
|
+
try {
|
|
1094
|
+
const parent = this.unwrap(await this.apiClient.get(`/folders/${parentId}`, { query: { fields: "title" } }));
|
|
1095
|
+
if (parent === null || parent === void 0 ? void 0 : parent.title) return `Inside "${parent.title}"`;
|
|
1096
|
+
return `Parent ID: ${parentId}`;
|
|
1097
|
+
} catch {
|
|
1098
|
+
return `Parent ID: ${parentId}`;
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
const oldParentInfo = currentFolder.parent_id ? await fetchParentInfo(currentFolder.parent_id) : "Top level";
|
|
1102
|
+
const newParentInfo = await (async () => {
|
|
1103
|
+
if (!updatedFolder.parent_id) return "Top level";
|
|
1104
|
+
if (updatedFolder.parent_id === currentFolder.parent_id) return oldParentInfo;
|
|
1105
|
+
return fetchParentInfo(updatedFolder.parent_id);
|
|
1106
|
+
})();
|
|
1022
1107
|
const resultLines = [];
|
|
1023
1108
|
resultLines.push(`✅ Successfully updated notebook!`);
|
|
1024
1109
|
resultLines.push("");
|
|
@@ -1039,23 +1124,25 @@ var EditFolder = class extends BaseTool {
|
|
|
1039
1124
|
if (updatedFolder.parent_id) resultLines.push(` - View parent notebook: read_notebook notebook_id="${updatedFolder.parent_id}"`);
|
|
1040
1125
|
return resultLines.join("\n");
|
|
1041
1126
|
} catch (error) {
|
|
1042
|
-
if (error
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1127
|
+
if (error instanceof ToolError) throw error;
|
|
1128
|
+
const err = error;
|
|
1129
|
+
process.stderr.write(`updating folder error: ${error}\n`);
|
|
1130
|
+
if (err.response) {
|
|
1131
|
+
if (err.response.status === 404) {
|
|
1132
|
+
var _err$config;
|
|
1133
|
+
if (((_err$config = err.config) === null || _err$config === void 0 || (_err$config = _err$config.url) === null || _err$config === void 0 ? void 0 : _err$config.includes(`/folders/${options.folder_id}`)) === true) throw new ToolError(`Failed to update folder: Folder with ID "${options.folder_id}" not found. Use list_notebooks to see available folders and their IDs.`);
|
|
1134
|
+
if (options.parent_id !== void 0) throw new ToolError(`Failed to update folder: Parent folder with ID "${options.parent_id}" not found. Use list_notebooks to see available folders and their IDs.`);
|
|
1047
1135
|
}
|
|
1048
|
-
if (
|
|
1049
|
-
var
|
|
1050
|
-
|
|
1136
|
+
if (err.response.status === 400) {
|
|
1137
|
+
var _err$response$data;
|
|
1138
|
+
throw new ToolError(`Failed to update folder: Invalid request data. ${((_err$response$data = err.response.data) === null || _err$response$data === void 0 ? void 0 : _err$response$data.error) ?? "Please check your input parameters."}`);
|
|
1051
1139
|
}
|
|
1052
|
-
if (
|
|
1140
|
+
if (err.response.status === 409) throw new ToolError(`Failed to update folder: A folder with the title "${options.title ?? ""}" might already exist in this location. Try a different title or check existing folders with list_notebooks.`);
|
|
1053
1141
|
}
|
|
1054
|
-
|
|
1142
|
+
throw new ToolError(`Failed to update folder: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1055
1143
|
}
|
|
1056
1144
|
}
|
|
1057
1145
|
};
|
|
1058
|
-
|
|
1059
1146
|
//#endregion
|
|
1060
1147
|
//#region src/lib/tools/edit-note.ts
|
|
1061
1148
|
var EditNote = class extends BaseTool {
|
|
@@ -1078,7 +1165,9 @@ var EditNote = class extends BaseTool {
|
|
|
1078
1165
|
}
|
|
1079
1166
|
try {
|
|
1080
1167
|
const currentNote = this.unwrap(await this.apiClient.get(`/notes/${options.note_id}`, { query: { fields: "id,title,body,parent_id,is_todo,todo_completed,todo_due,updated_time" } }));
|
|
1081
|
-
|
|
1168
|
+
const currentNoteError = extractJoplinErrorMessage(currentNote);
|
|
1169
|
+
if (currentNoteError !== void 0) throw new ToolError(`Failed to load note "${options.note_id}": ${currentNoteError}`);
|
|
1170
|
+
if (!(currentNote === null || currentNote === void 0 ? void 0 : currentNote.id)) throw new ToolError(`Note with ID "${options.note_id}" not found. Use search_notes to find notes and their IDs.`);
|
|
1082
1171
|
const updateBody = {};
|
|
1083
1172
|
if (options.title !== void 0) updateBody.title = options.title;
|
|
1084
1173
|
if (options.body !== void 0) updateBody.body = options.body;
|
|
@@ -1088,22 +1177,24 @@ var EditNote = class extends BaseTool {
|
|
|
1088
1177
|
if (options.todo_completed !== void 0) updateBody.todo_completed = options.todo_completed;
|
|
1089
1178
|
if (options.todo_due !== void 0) updateBody.todo_due = options.todo_due;
|
|
1090
1179
|
const updatedNote = this.unwrap(await this.apiClient.put(`/notes/${options.note_id}`, updateBody));
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1180
|
+
const joplinError = extractJoplinErrorMessage(updatedNote);
|
|
1181
|
+
if (joplinError !== void 0) throw new ToolError(`Failed to update note: ${joplinError}`);
|
|
1182
|
+
if (!updatedNote || typeof updatedNote !== "object" || !updatedNote.id) throw new ToolError(`Failed to update note: Joplin API returned an unexpected response (no note id). Raw response: ${JSON.stringify(updatedNote)}`);
|
|
1183
|
+
const fetchNotebookInfo = async (parentId) => {
|
|
1184
|
+
try {
|
|
1185
|
+
const notebook = this.unwrap(await this.apiClient.get(`/folders/${parentId}`, { query: { fields: "title" } }));
|
|
1186
|
+
if (notebook === null || notebook === void 0 ? void 0 : notebook.title) return `"${notebook.title}"`;
|
|
1187
|
+
return `Notebook ID: ${parentId}`;
|
|
1188
|
+
} catch {
|
|
1189
|
+
return `Notebook ID: ${parentId}`;
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
const oldNotebookInfo = currentNote.parent_id ? await fetchNotebookInfo(currentNote.parent_id) : "Root level";
|
|
1193
|
+
const newNotebookInfo = await (async () => {
|
|
1194
|
+
if (!updatedNote.parent_id) return "Root level";
|
|
1195
|
+
if (updatedNote.parent_id === currentNote.parent_id) return oldNotebookInfo;
|
|
1196
|
+
return fetchNotebookInfo(updatedNote.parent_id);
|
|
1197
|
+
})();
|
|
1107
1198
|
const resultLines = [];
|
|
1108
1199
|
resultLines.push(`✅ Successfully updated note!`);
|
|
1109
1200
|
resultLines.push("");
|
|
@@ -1138,19 +1229,21 @@ var EditNote = class extends BaseTool {
|
|
|
1138
1229
|
if (updatedNote.parent_id) resultLines.push(` - View notebook: read_notebook notebook_id="${updatedNote.parent_id}"`);
|
|
1139
1230
|
return resultLines.join("\n");
|
|
1140
1231
|
} catch (error) {
|
|
1141
|
-
if (error
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1232
|
+
if (error instanceof ToolError) throw error;
|
|
1233
|
+
const err = error;
|
|
1234
|
+
process.stderr.write(`updating note error: ${error}\n`);
|
|
1235
|
+
if (err.response) {
|
|
1236
|
+
if (err.response.status === 404 && options.parent_id !== void 0) throw new ToolError(`Failed to update note: Notebook with ID "${options.parent_id}" not found. Use list_notebooks to see available notebooks and their IDs.`);
|
|
1237
|
+
if (err.response.status === 404) throw new ToolError(`Failed to update note: Note with ID "${options.note_id}" not found. Use search_notes to find notes and their IDs.`);
|
|
1238
|
+
if (err.response.status === 400) {
|
|
1239
|
+
var _err$response$data;
|
|
1240
|
+
throw new ToolError(`Failed to update note: Invalid request data. ${((_err$response$data = err.response.data) === null || _err$response$data === void 0 ? void 0 : _err$response$data.error) ?? "Please check your input parameters."}`);
|
|
1146
1241
|
}
|
|
1147
|
-
if (error.response.status === 404 && options.parent_id) return `Error: Notebook with ID "${options.parent_id}" not found.\n\nUse list_notebooks to see available notebooks and their IDs.`;
|
|
1148
1242
|
}
|
|
1149
|
-
|
|
1243
|
+
throw new ToolError(`Failed to update note: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1150
1244
|
}
|
|
1151
1245
|
}
|
|
1152
1246
|
};
|
|
1153
|
-
|
|
1154
1247
|
//#endregion
|
|
1155
1248
|
//#region src/lib/tools/list-notebooks.ts
|
|
1156
1249
|
var ListNotebooks = class extends BaseTool {
|
|
@@ -1159,7 +1252,7 @@ var ListNotebooks = class extends BaseTool {
|
|
|
1159
1252
|
const notebooks = this.unwrap(await this.apiClient.getAllItems("/folders", { query: { fields: "id,title,parent_id" } }));
|
|
1160
1253
|
const notebooksByParentId = {};
|
|
1161
1254
|
notebooks.forEach((notebook) => {
|
|
1162
|
-
const parentId = notebook.parent_id
|
|
1255
|
+
const parentId = notebook.parent_id ?? "";
|
|
1163
1256
|
if (!notebooksByParentId[parentId]) notebooksByParentId[parentId] = [];
|
|
1164
1257
|
notebooksByParentId[parentId].push(notebook);
|
|
1165
1258
|
});
|
|
@@ -1168,20 +1261,22 @@ var ListNotebooks = class extends BaseTool {
|
|
|
1168
1261
|
"NOTE: To read a notebook, use the notebook_id with the read_notebook command\n",
|
|
1169
1262
|
"Example: read_notebook notebook_id=\"your-notebook-id\"\n\n"
|
|
1170
1263
|
];
|
|
1171
|
-
resultLines.push(...this.notebooksLines(notebooksByParentId[""]
|
|
1264
|
+
resultLines.push(...this.notebooksLines(notebooksByParentId[""] ?? [], {
|
|
1172
1265
|
indent: 0,
|
|
1173
1266
|
notebooksByParentId
|
|
1174
1267
|
}));
|
|
1175
1268
|
return resultLines.join("");
|
|
1176
1269
|
} catch (error) {
|
|
1177
|
-
|
|
1270
|
+
if (error instanceof ToolError) throw error;
|
|
1271
|
+
process.stderr.write(`listing notebooks error: ${error}\n`);
|
|
1272
|
+
throw new ToolError(`Failed to list notebooks: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1178
1273
|
}
|
|
1179
1274
|
}
|
|
1180
1275
|
notebooksLines(notebooks, { indent = 0, notebooksByParentId }) {
|
|
1181
1276
|
const result = [];
|
|
1182
1277
|
const indentSpaces = " ".repeat(indent);
|
|
1183
1278
|
this.sortNotebooks(notebooks).forEach((notebook) => {
|
|
1184
|
-
const id = notebook
|
|
1279
|
+
const { id } = notebook;
|
|
1185
1280
|
result.push(`${indentSpaces}Notebook: "${notebook.title}" (notebook_id: "${id}")\n`);
|
|
1186
1281
|
const childNotebooks = notebooksByParentId[id];
|
|
1187
1282
|
if (childNotebooks) result.push(...this.notebooksLines(childNotebooks, {
|
|
@@ -1200,7 +1295,6 @@ var ListNotebooks = class extends BaseTool {
|
|
|
1200
1295
|
});
|
|
1201
1296
|
}
|
|
1202
1297
|
};
|
|
1203
|
-
|
|
1204
1298
|
//#endregion
|
|
1205
1299
|
//#region src/lib/tools/read-multi-note.ts
|
|
1206
1300
|
var ReadMultiNote = class extends BaseTool {
|
|
@@ -1213,8 +1307,7 @@ var ReadMultiNote = class extends BaseTool {
|
|
|
1213
1307
|
const errors = [];
|
|
1214
1308
|
const successful = [];
|
|
1215
1309
|
resultLines.push(`# Reading ${noteIds.length} notes\n`);
|
|
1216
|
-
for (
|
|
1217
|
-
const noteId = noteIds[i];
|
|
1310
|
+
for (const [i, noteId] of noteIds.entries()) {
|
|
1218
1311
|
resultLines.push(`## Note ${i + 1} of ${noteIds.length} (ID: ${noteId})\n`);
|
|
1219
1312
|
try {
|
|
1220
1313
|
const note = this.unwrap(await this.apiClient.get(`/notes/${noteId}`, { query: { fields: "id,title,body,parent_id,created_time,updated_time,is_todo,todo_completed,todo_due" } }));
|
|
@@ -1224,13 +1317,17 @@ var ReadMultiNote = class extends BaseTool {
|
|
|
1224
1317
|
continue;
|
|
1225
1318
|
}
|
|
1226
1319
|
successful.push(noteId);
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1320
|
+
const notebookInfo = await (async () => {
|
|
1321
|
+
if (!note.parent_id) return "Unknown notebook";
|
|
1322
|
+
try {
|
|
1323
|
+
const notebook = this.unwrap(await this.apiClient.get(`/folders/${note.parent_id}`, { query: { fields: "id,title" } }));
|
|
1324
|
+
if (notebook === null || notebook === void 0 ? void 0 : notebook.title) return `"${notebook.title}" (notebook_id: "${note.parent_id}")`;
|
|
1325
|
+
return "Unknown notebook";
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
process.stderr.write(`Error fetching notebook info for note ${noteId}: ${err}\n`);
|
|
1328
|
+
return "Unknown notebook";
|
|
1329
|
+
}
|
|
1330
|
+
})();
|
|
1234
1331
|
resultLines.push(`### Note: "${note.title}"`);
|
|
1235
1332
|
resultLines.push(`Notebook: ${notebookInfo}`);
|
|
1236
1333
|
if (note.is_todo) {
|
|
@@ -1250,13 +1347,15 @@ var ReadMultiNote = class extends BaseTool {
|
|
|
1250
1347
|
else resultLines.push("(This note has no content)");
|
|
1251
1348
|
resultLines.push("\n---\n");
|
|
1252
1349
|
} catch (error) {
|
|
1350
|
+
var _err$response;
|
|
1253
1351
|
process.stderr.write(`Error reading note ${noteId}: ${error}\n`);
|
|
1254
|
-
|
|
1352
|
+
const err = error;
|
|
1353
|
+
if (((_err$response = err.response) === null || _err$response === void 0 ? void 0 : _err$response.status) === 404) {
|
|
1255
1354
|
notFound.push(noteId);
|
|
1256
1355
|
resultLines.push(`Note with ID "${noteId}" not found.\n`);
|
|
1257
1356
|
} else {
|
|
1258
1357
|
errors.push(noteId);
|
|
1259
|
-
resultLines.push(`Error reading note: ${
|
|
1358
|
+
resultLines.push(`Error reading note: ${err.message ?? "Unknown error"}\n`);
|
|
1260
1359
|
}
|
|
1261
1360
|
}
|
|
1262
1361
|
}
|
|
@@ -1274,7 +1373,6 @@ var ReadMultiNote = class extends BaseTool {
|
|
|
1274
1373
|
return resultLines.join("\n");
|
|
1275
1374
|
}
|
|
1276
1375
|
};
|
|
1277
|
-
|
|
1278
1376
|
//#endregion
|
|
1279
1377
|
//#region src/lib/tools/read-note.ts
|
|
1280
1378
|
var ReadNote = class extends BaseTool {
|
|
@@ -1283,14 +1381,20 @@ var ReadNote = class extends BaseTool {
|
|
|
1283
1381
|
if (validationError) return validationError;
|
|
1284
1382
|
try {
|
|
1285
1383
|
const note = this.unwrap(await this.apiClient.get(`/notes/${noteId}`, { query: { fields: "id,title,body,parent_id,created_time,updated_time,is_todo,todo_completed,todo_due" } }));
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
if (note.
|
|
1289
|
-
|
|
1290
|
-
if (
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1384
|
+
const noteLoadError = extractJoplinErrorMessage(note);
|
|
1385
|
+
if (noteLoadError !== void 0) throw new ToolError(`Failed to read note "${noteId}": ${noteLoadError}`);
|
|
1386
|
+
if (!note || typeof note !== "object" || !note.id) throw new ToolError(`Failed to read note "${noteId}": Joplin API returned an unexpected response (no note id). Raw response: ${JSON.stringify(note)}`);
|
|
1387
|
+
const notebookInfo = await (async () => {
|
|
1388
|
+
if (!note.parent_id) return "Unknown notebook";
|
|
1389
|
+
try {
|
|
1390
|
+
const notebook = this.unwrap(await this.apiClient.get(`/folders/${note.parent_id}`, { query: { fields: "id,title" } }));
|
|
1391
|
+
if (notebook === null || notebook === void 0 ? void 0 : notebook.title) return `"${notebook.title}" (notebook_id: "${note.parent_id}")`;
|
|
1392
|
+
return "Unknown notebook";
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
process.stderr.write(`Error fetching notebook info: ${err}\n`);
|
|
1395
|
+
return "Unknown notebook";
|
|
1396
|
+
}
|
|
1397
|
+
})();
|
|
1294
1398
|
const resultLines = [];
|
|
1295
1399
|
resultLines.push(`# Note: "${note.title}"`);
|
|
1296
1400
|
resultLines.push(`Note ID: ${note.id}`);
|
|
@@ -1316,12 +1420,15 @@ var ReadNote = class extends BaseTool {
|
|
|
1316
1420
|
resultLines.push("- To search for more notes: search_notes query=\"your search term\"");
|
|
1317
1421
|
return resultLines.join("\n");
|
|
1318
1422
|
} catch (error) {
|
|
1319
|
-
|
|
1320
|
-
|
|
1423
|
+
var _err$response;
|
|
1424
|
+
if (error instanceof ToolError) throw error;
|
|
1425
|
+
const err = error;
|
|
1426
|
+
process.stderr.write(`reading note error: ${error}\n`);
|
|
1427
|
+
if (((_err$response = err.response) === null || _err$response === void 0 ? void 0 : _err$response.status) === 404) throw new ToolError(`Note with ID "${noteId}" not found. This might happen if the ID is incorrect, you're using a notebook ID instead of a note ID, or the note has been deleted. Use search_notes to find notes and their IDs.`);
|
|
1428
|
+
throw new ToolError(`Failed to read note "${noteId}": ${error instanceof Error ? error.message : "Unknown error"}. Make sure you're using a valid note ID. Use search_notes to find notes and their IDs.`);
|
|
1321
1429
|
}
|
|
1322
1430
|
}
|
|
1323
1431
|
};
|
|
1324
|
-
|
|
1325
1432
|
//#endregion
|
|
1326
1433
|
//#region src/lib/tools/read-notebook.ts
|
|
1327
1434
|
var ReadNotebook = class extends BaseTool {
|
|
@@ -1330,9 +1437,13 @@ var ReadNotebook = class extends BaseTool {
|
|
|
1330
1437
|
if (validationError) return validationError;
|
|
1331
1438
|
try {
|
|
1332
1439
|
const notebook = this.unwrap(await this.apiClient.get(`/folders/${notebookId}`, { query: { fields: "id,title,parent_id" } }));
|
|
1333
|
-
|
|
1440
|
+
const notebookLoadError = extractJoplinErrorMessage(notebook);
|
|
1441
|
+
if (notebookLoadError !== void 0) throw new ToolError(`Failed to read notebook "${notebookId}": ${notebookLoadError}`);
|
|
1442
|
+
if (!notebook || typeof notebook !== "object" || !notebook.id) throw new ToolError(`Failed to read notebook "${notebookId}": Joplin API returned an unexpected response (no notebook id). Raw response: ${JSON.stringify(notebook)}`);
|
|
1334
1443
|
const notes = this.unwrap(await this.apiClient.get(`/folders/${notebookId}/notes`, { query: { fields: "id,title,updated_time,is_todo,todo_completed" } }));
|
|
1335
|
-
|
|
1444
|
+
const notesLoadError = extractJoplinErrorMessage(notes);
|
|
1445
|
+
if (notesLoadError !== void 0) throw new ToolError(`Failed to load notes for notebook "${notebookId}": ${notesLoadError}`);
|
|
1446
|
+
if (!notes || typeof notes !== "object") throw new ToolError(`Failed to load notes for notebook "${notebookId}": Joplin API returned an unexpected response. Raw response: ${JSON.stringify(notes)}`);
|
|
1336
1447
|
if (!notes.items || !Array.isArray(notes.items) || notes.items.length === 0) return `Notebook "${notebook.title}" (notebook_id: "${notebook.id}") is empty.\n\nTry another notebook ID or use list_notebooks to see all available notebooks.`;
|
|
1337
1448
|
const resultLines = [];
|
|
1338
1449
|
resultLines.push(`# Notebook: "${notebook.title}" (notebook_id: "${notebook.id}")`);
|
|
@@ -1355,12 +1466,15 @@ var ReadNotebook = class extends BaseTool {
|
|
|
1355
1466
|
});
|
|
1356
1467
|
return resultLines.join("\n");
|
|
1357
1468
|
} catch (error) {
|
|
1358
|
-
|
|
1359
|
-
|
|
1469
|
+
var _err$response;
|
|
1470
|
+
if (error instanceof ToolError) throw error;
|
|
1471
|
+
const err = error;
|
|
1472
|
+
process.stderr.write(`reading notebook error: ${error}\n`);
|
|
1473
|
+
if (((_err$response = err.response) === null || _err$response === void 0 ? void 0 : _err$response.status) === 404) throw new ToolError(`Notebook with ID "${notebookId}" not found. This might happen if the ID is incorrect, you're using a note title instead of a notebook ID, or the notebook has been deleted. Use list_notebooks to see all available notebooks with their IDs.`);
|
|
1474
|
+
throw new ToolError(`Failed to read notebook "${notebookId}": ${error instanceof Error ? error.message : "Unknown error"}. Make sure you're using a valid notebook ID, not a note title. Use list_notebooks to see all available notebooks with their IDs.`);
|
|
1360
1475
|
}
|
|
1361
1476
|
}
|
|
1362
1477
|
};
|
|
1363
|
-
|
|
1364
1478
|
//#endregion
|
|
1365
1479
|
//#region src/lib/tools/search-notes.ts
|
|
1366
1480
|
var SearchNotes = class extends BaseTool {
|
|
@@ -1387,8 +1501,8 @@ var SearchNotes = class extends BaseTool {
|
|
|
1387
1501
|
resultLines.push(`read_multinote note_ids=${JSON.stringify(noteIds)}\n`);
|
|
1388
1502
|
}
|
|
1389
1503
|
searchResults.items.forEach((note) => {
|
|
1390
|
-
const notebookTitle = folderMap[note.parent_id
|
|
1391
|
-
const notebookId = note.parent_id
|
|
1504
|
+
const notebookTitle = folderMap[note.parent_id ?? ""] ?? "Unknown notebook";
|
|
1505
|
+
const notebookId = note.parent_id ?? "unknown";
|
|
1392
1506
|
const updatedDate = this.formatDate(note.updated_time);
|
|
1393
1507
|
resultLines.push(`- Note: "${note.title}" (note_id: "${note.id}")`);
|
|
1394
1508
|
resultLines.push(` Notebook: "${notebookTitle}" (notebook_id: "${notebookId}")`);
|
|
@@ -1403,11 +1517,109 @@ var SearchNotes = class extends BaseTool {
|
|
|
1403
1517
|
});
|
|
1404
1518
|
return resultLines.join("\n");
|
|
1405
1519
|
} catch (error) {
|
|
1406
|
-
|
|
1520
|
+
if (error instanceof ToolError) throw error;
|
|
1521
|
+
process.stderr.write(`searching notes error: ${error}\n`);
|
|
1522
|
+
throw new ToolError(`Failed to search notes: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1407
1523
|
}
|
|
1408
1524
|
}
|
|
1409
1525
|
};
|
|
1410
|
-
|
|
1526
|
+
//#endregion
|
|
1527
|
+
//#region src/lib/joplin-api-client.ts
|
|
1528
|
+
var JoplinAPIClient = class {
|
|
1529
|
+
baseURL;
|
|
1530
|
+
token;
|
|
1531
|
+
constructor({ host = "127.0.0.1", port = 41184, token }) {
|
|
1532
|
+
this.baseURL = `http://${host}:${port}`;
|
|
1533
|
+
this.token = token;
|
|
1534
|
+
}
|
|
1535
|
+
async serviceAvailable() {
|
|
1536
|
+
try {
|
|
1537
|
+
const response = await axios.get(`${this.baseURL}/ping`, { timeout: 5e3 });
|
|
1538
|
+
if (response.status === 200 && response.data === "JoplinClipperServer") return Right(true);
|
|
1539
|
+
return Left(/* @__PURE__ */ new Error("Unexpected response from Joplin ping"));
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
async getAllItems(path, options = {}) {
|
|
1545
|
+
const fetchPage = async (page, acc) => {
|
|
1546
|
+
const response = (await this.get(path, this.mergeRequestOptions(options, { query: { page } }))).fold((err) => {
|
|
1547
|
+
throw err;
|
|
1548
|
+
}, (data) => data);
|
|
1549
|
+
if (!Array.isArray(response.items)) throw new Error(`Unexpected response format from Joplin API for path: ${path}`);
|
|
1550
|
+
const combined = [...acc, ...response.items];
|
|
1551
|
+
return response.has_more ? fetchPage(page + 1, combined) : combined;
|
|
1552
|
+
};
|
|
1553
|
+
try {
|
|
1554
|
+
return Right(await fetchPage(1, []));
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
process.stderr.write(`Error in getAllItems for path ${path}: ${error}\n`);
|
|
1557
|
+
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
async get(path, options = {}) {
|
|
1561
|
+
try {
|
|
1562
|
+
const { data } = await axios.get(`${this.baseURL}${path}`, {
|
|
1563
|
+
params: this.requestOptions(options).query,
|
|
1564
|
+
timeout: 3e4
|
|
1565
|
+
});
|
|
1566
|
+
return Right(data);
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
async post(path, body, options = {}) {
|
|
1572
|
+
try {
|
|
1573
|
+
const { data } = await axios.post(`${this.baseURL}${path}`, body, {
|
|
1574
|
+
params: this.requestOptions(options).query,
|
|
1575
|
+
timeout: 3e4
|
|
1576
|
+
});
|
|
1577
|
+
return Right(data);
|
|
1578
|
+
} catch (error) {
|
|
1579
|
+
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
async delete(path, options = {}) {
|
|
1583
|
+
try {
|
|
1584
|
+
const { data } = await axios.delete(`${this.baseURL}${path}`, {
|
|
1585
|
+
params: this.requestOptions(options).query,
|
|
1586
|
+
timeout: 3e4
|
|
1587
|
+
});
|
|
1588
|
+
return Right(data);
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
async put(path, body, options = {}) {
|
|
1594
|
+
try {
|
|
1595
|
+
const { data } = await axios.put(`${this.baseURL}${path}`, body, {
|
|
1596
|
+
params: this.requestOptions(options).query,
|
|
1597
|
+
timeout: 3e4
|
|
1598
|
+
});
|
|
1599
|
+
return Right(data);
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
return Left(error instanceof Error ? error : new Error(String(error)));
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
requestOptions(options = {}) {
|
|
1605
|
+
return this.mergeRequestOptions({ query: { token: this.token } }, options);
|
|
1606
|
+
}
|
|
1607
|
+
mergeRequestOptions(options1, options2) {
|
|
1608
|
+
return {
|
|
1609
|
+
query: {
|
|
1610
|
+
...options1.query ?? {},
|
|
1611
|
+
...options2.query ?? {}
|
|
1612
|
+
},
|
|
1613
|
+
...this.except(options1, "query"),
|
|
1614
|
+
...this.except(options2, "query")
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
except(obj, key) {
|
|
1618
|
+
const result = { ...obj };
|
|
1619
|
+
delete result[key];
|
|
1620
|
+
return result;
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1411
1623
|
//#endregion
|
|
1412
1624
|
//#region src/server-core.ts
|
|
1413
1625
|
var JoplinServerManager = class {
|
|
@@ -1514,7 +1726,7 @@ var JoplinServerManager = class {
|
|
|
1514
1726
|
const desktopWarning = ((_this$config$sidecar = this.config.sidecar) === null || _this$config$sidecar === void 0 ? void 0 : _this$config$sidecar.isDesktopDetected()) ? "\n\nNote: Joplin Desktop is also running. The sidecar and Desktop use separate databases. Notes sync between them only if both are configured with the same sync target." : "";
|
|
1515
1727
|
return (await this.apiClient.post("/services/sync", { action: "start" })).fold((error) => {
|
|
1516
1728
|
const msg = error.message || String(error);
|
|
1517
|
-
if (msg.includes("404") || msg.includes("No action API") || msg.includes("No such service")) return
|
|
1729
|
+
if (msg.includes("404") || msg.includes("No action API") || msg.includes("No such service")) return `Sync is managed automatically by the Joplin server on its configured interval (default: every 5 minutes). On-demand sync is not available via the Joplin Terminal API.${desktopWarning}`;
|
|
1518
1730
|
return `Sync failed: ${msg}${desktopWarning}`;
|
|
1519
1731
|
}, () => `Sync triggered successfully.${desktopWarning}`);
|
|
1520
1732
|
}
|
|
@@ -1522,9 +1734,21 @@ var JoplinServerManager = class {
|
|
|
1522
1734
|
function initializeJoplinManager(config) {
|
|
1523
1735
|
return new JoplinServerManager(config);
|
|
1524
1736
|
}
|
|
1525
|
-
|
|
1526
1737
|
//#endregion
|
|
1527
1738
|
//#region src/server-fastmcp.ts
|
|
1739
|
+
/**
|
|
1740
|
+
* Convert a ToolError thrown by a tool into a FastMCP UserError so the error
|
|
1741
|
+
* message reaches the agent verbatim with isError: true (instead of being
|
|
1742
|
+
* wrapped in FastMCP's generic "Tool 'x' execution failed: ..." envelope).
|
|
1743
|
+
*/
|
|
1744
|
+
const runWithUserError = async (fn) => {
|
|
1745
|
+
try {
|
|
1746
|
+
return await fn();
|
|
1747
|
+
} catch (e) {
|
|
1748
|
+
if (e instanceof ToolError) throw new UserError(e.message);
|
|
1749
|
+
throw e;
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1528
1752
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
1529
1753
|
const VERSION = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
|
|
1530
1754
|
function createFastMCPServer(options) {
|
|
@@ -1554,41 +1778,31 @@ function createFastMCPServer(options) {
|
|
|
1554
1778
|
name: "list_notebooks",
|
|
1555
1779
|
description: "Retrieve the complete notebook hierarchy from Joplin",
|
|
1556
1780
|
parameters: z.object({}),
|
|
1557
|
-
execute:
|
|
1558
|
-
return await manager.listNotebooks();
|
|
1559
|
-
}
|
|
1781
|
+
execute: () => runWithUserError(() => manager.listNotebooks())
|
|
1560
1782
|
});
|
|
1561
1783
|
server.addTool({
|
|
1562
1784
|
name: "search_notes",
|
|
1563
1785
|
description: "Search for notes in Joplin and return matching notebooks",
|
|
1564
1786
|
parameters: z.object({ query: z.string().describe("Search query for notes") }),
|
|
1565
|
-
execute:
|
|
1566
|
-
return await manager.searchNotes(args.query);
|
|
1567
|
-
}
|
|
1787
|
+
execute: (args) => runWithUserError(() => manager.searchNotes(args.query))
|
|
1568
1788
|
});
|
|
1569
1789
|
server.addTool({
|
|
1570
1790
|
name: "read_notebook",
|
|
1571
1791
|
description: "Read the contents of a specific notebook",
|
|
1572
1792
|
parameters: z.object({ notebook_id: z.string().describe("ID of the notebook to read") }),
|
|
1573
|
-
execute:
|
|
1574
|
-
return await manager.readNotebook(args.notebook_id);
|
|
1575
|
-
}
|
|
1793
|
+
execute: (args) => runWithUserError(() => manager.readNotebook(args.notebook_id))
|
|
1576
1794
|
});
|
|
1577
1795
|
server.addTool({
|
|
1578
1796
|
name: "read_note",
|
|
1579
1797
|
description: "Read the full content of a specific note",
|
|
1580
1798
|
parameters: z.object({ note_id: z.string().describe("ID of the note to read") }),
|
|
1581
|
-
execute:
|
|
1582
|
-
return await manager.readNote(args.note_id);
|
|
1583
|
-
}
|
|
1799
|
+
execute: (args) => runWithUserError(() => manager.readNote(args.note_id))
|
|
1584
1800
|
});
|
|
1585
1801
|
server.addTool({
|
|
1586
1802
|
name: "read_multinote",
|
|
1587
1803
|
description: "Read the full content of multiple notes at once",
|
|
1588
1804
|
parameters: z.object({ note_ids: z.array(z.string()).describe("Array of note IDs to read") }),
|
|
1589
|
-
execute:
|
|
1590
|
-
return await manager.readMultiNote(args.note_ids);
|
|
1591
|
-
}
|
|
1805
|
+
execute: (args) => runWithUserError(() => manager.readMultiNote(args.note_ids))
|
|
1592
1806
|
});
|
|
1593
1807
|
server.addTool({
|
|
1594
1808
|
name: "create_note",
|
|
@@ -1601,9 +1815,7 @@ function createFastMCPServer(options) {
|
|
|
1601
1815
|
is_todo: z.boolean().optional().describe("Whether this is a todo note"),
|
|
1602
1816
|
image_data_url: z.string().optional().describe("Base64 encoded image data URL")
|
|
1603
1817
|
}),
|
|
1604
|
-
execute:
|
|
1605
|
-
return await manager.createNote(args);
|
|
1606
|
-
}
|
|
1818
|
+
execute: (args) => runWithUserError(() => manager.createNote(args))
|
|
1607
1819
|
});
|
|
1608
1820
|
server.addTool({
|
|
1609
1821
|
name: "create_folder",
|
|
@@ -1612,9 +1824,7 @@ function createFastMCPServer(options) {
|
|
|
1612
1824
|
title: z.string().describe("Notebook title"),
|
|
1613
1825
|
parent_id: z.string().optional().describe("ID of parent notebook")
|
|
1614
1826
|
}),
|
|
1615
|
-
execute:
|
|
1616
|
-
return await manager.createFolder(args);
|
|
1617
|
-
}
|
|
1827
|
+
execute: (args) => runWithUserError(() => manager.createFolder(args))
|
|
1618
1828
|
});
|
|
1619
1829
|
server.addTool({
|
|
1620
1830
|
name: "edit_note",
|
|
@@ -1629,9 +1839,7 @@ function createFastMCPServer(options) {
|
|
|
1629
1839
|
todo_completed: z.boolean().optional().describe("Whether todo is completed"),
|
|
1630
1840
|
todo_due: z.number().optional().describe("Todo due date (Unix timestamp)")
|
|
1631
1841
|
}),
|
|
1632
|
-
execute:
|
|
1633
|
-
return await manager.editNote(args);
|
|
1634
|
-
}
|
|
1842
|
+
execute: (args) => runWithUserError(() => manager.editNote(args))
|
|
1635
1843
|
});
|
|
1636
1844
|
server.addTool({
|
|
1637
1845
|
name: "edit_folder",
|
|
@@ -1641,9 +1849,7 @@ function createFastMCPServer(options) {
|
|
|
1641
1849
|
title: z.string().optional().describe("New folder title"),
|
|
1642
1850
|
parent_id: z.string().optional().describe("New parent folder ID")
|
|
1643
1851
|
}),
|
|
1644
|
-
execute:
|
|
1645
|
-
return await manager.editFolder(args);
|
|
1646
|
-
}
|
|
1852
|
+
execute: (args) => runWithUserError(() => manager.editFolder(args))
|
|
1647
1853
|
});
|
|
1648
1854
|
server.addTool({
|
|
1649
1855
|
name: "delete_note",
|
|
@@ -1652,9 +1858,7 @@ function createFastMCPServer(options) {
|
|
|
1652
1858
|
note_id: z.string().describe("ID of the note to delete"),
|
|
1653
1859
|
confirm: z.boolean().optional().describe("Confirmation flag")
|
|
1654
1860
|
}),
|
|
1655
|
-
execute:
|
|
1656
|
-
return await manager.deleteNote(args);
|
|
1657
|
-
}
|
|
1861
|
+
execute: (args) => runWithUserError(() => manager.deleteNote(args))
|
|
1658
1862
|
});
|
|
1659
1863
|
server.addTool({
|
|
1660
1864
|
name: "delete_folder",
|
|
@@ -1664,17 +1868,13 @@ function createFastMCPServer(options) {
|
|
|
1664
1868
|
confirm: z.boolean().optional().describe("Confirmation flag"),
|
|
1665
1869
|
force: z.boolean().optional().describe("Force delete even if folder has contents")
|
|
1666
1870
|
}),
|
|
1667
|
-
execute:
|
|
1668
|
-
return await manager.deleteFolder(args);
|
|
1669
|
-
}
|
|
1871
|
+
execute: (args) => runWithUserError(() => manager.deleteFolder(args))
|
|
1670
1872
|
});
|
|
1671
1873
|
server.addTool({
|
|
1672
1874
|
name: "sync",
|
|
1673
1875
|
description: "Trigger a Joplin sync to push/pull changes with the configured sync target",
|
|
1674
1876
|
parameters: z.object({}),
|
|
1675
|
-
execute:
|
|
1676
|
-
return await manager.sync();
|
|
1677
|
-
}
|
|
1877
|
+
execute: () => runWithUserError(() => manager.sync())
|
|
1678
1878
|
});
|
|
1679
1879
|
process.stderr.write("FastMCP server configured with 12 Joplin tools\n");
|
|
1680
1880
|
return {
|
|
@@ -1685,8 +1885,8 @@ function createFastMCPServer(options) {
|
|
|
1685
1885
|
async function startFastMCPServer(options) {
|
|
1686
1886
|
const { server } = createFastMCPServer(options);
|
|
1687
1887
|
process.stderr.write(`Configured for Joplin at ${options.host}:${options.port}\n`);
|
|
1688
|
-
const port = options.httpPort
|
|
1689
|
-
const endpoint = options.endpoint
|
|
1888
|
+
const port = options.httpPort ?? 3e3;
|
|
1889
|
+
const endpoint = options.endpoint ?? "/mcp";
|
|
1690
1890
|
await server.start({
|
|
1691
1891
|
transportType: "httpStream",
|
|
1692
1892
|
httpStream: {
|
|
@@ -1696,66 +1896,89 @@ async function startFastMCPServer(options) {
|
|
|
1696
1896
|
});
|
|
1697
1897
|
process.stderr.write(`FastMCP server running on http://0.0.0.0:${port}${endpoint}\n`);
|
|
1698
1898
|
}
|
|
1699
|
-
|
|
1700
1899
|
//#endregion
|
|
1701
1900
|
//#region src/index.ts
|
|
1702
|
-
const { transport, httpPort, profileDir, syncTarget } = parseArgs();
|
|
1901
|
+
const { transport, httpPort, profileDir, syncTarget, explicitToken } = parseArgs();
|
|
1703
1902
|
const isHttpMode = transport === "http";
|
|
1704
|
-
const externalHost = process.env.JOPLIN_HOST;
|
|
1705
|
-
const externalPort = process.env.JOPLIN_PORT ? parseInt(process.env.JOPLIN_PORT, 10) : void 0;
|
|
1706
|
-
const externalMode =
|
|
1707
|
-
|
|
1708
|
-
|
|
1903
|
+
const externalHost = process.env.JOPLIN_HOST !== void 0 && process.env.JOPLIN_HOST !== "" ? process.env.JOPLIN_HOST : void 0;
|
|
1904
|
+
const externalPort = process.env.JOPLIN_PORT !== void 0 && process.env.JOPLIN_PORT !== "" ? parseInt(process.env.JOPLIN_PORT, 10) : void 0;
|
|
1905
|
+
const externalMode = externalHost !== void 0 || externalPort !== void 0;
|
|
1906
|
+
const generateToken = () => `mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 32)}`;
|
|
1907
|
+
const explicitTokenValue = explicitToken.orUndefined();
|
|
1908
|
+
const ambientToken = process.env.JOPLIN_TOKEN;
|
|
1909
|
+
const tokenInput = {
|
|
1910
|
+
externalMode,
|
|
1911
|
+
explicitToken,
|
|
1912
|
+
ambientToken: Option(ambientToken)
|
|
1913
|
+
};
|
|
1914
|
+
if (externalTokenMissing(tokenInput)) {
|
|
1915
|
+
process.stderr.write("Error: a token is required in external mode. Use --token <token> or set JOPLIN_TOKEN environment variable.\n");
|
|
1709
1916
|
process.exit(1);
|
|
1710
1917
|
}
|
|
1711
|
-
const
|
|
1712
|
-
if (process.env.JOPLIN_TOKEN) return process.env.JOPLIN_TOKEN;
|
|
1713
|
-
if (externalMode) return `mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 32)}`;
|
|
1918
|
+
const readOrCreateProfileToken = () => {
|
|
1714
1919
|
const tokenPath = path.join(profileDir, ".mcp-token");
|
|
1715
1920
|
try {
|
|
1716
1921
|
const saved = fs.readFileSync(tokenPath, "utf-8").trim();
|
|
1717
1922
|
if (saved) return saved;
|
|
1718
1923
|
} catch {}
|
|
1719
|
-
const token =
|
|
1924
|
+
const token = generateToken();
|
|
1720
1925
|
try {
|
|
1721
1926
|
fs.mkdirSync(profileDir, { recursive: true });
|
|
1722
1927
|
fs.writeFileSync(tokenPath, token);
|
|
1723
1928
|
} catch {}
|
|
1724
1929
|
return token;
|
|
1930
|
+
};
|
|
1931
|
+
const tokenResolution = resolveTokenSource(tokenInput);
|
|
1932
|
+
if (tokenResolution.ambientIgnored) process.stderr.write("Note: ignoring JOPLIN_TOKEN in sidecar mode; the sidecar manages its own token in the profile. Pass --token to set one explicitly, or JOPLIN_HOST/JOPLIN_PORT to connect to an external Joplin.\n");
|
|
1933
|
+
const joplinToken = (() => {
|
|
1934
|
+
switch (tokenResolution.source) {
|
|
1935
|
+
case "explicit": return explicitTokenValue ?? generateToken();
|
|
1936
|
+
case "external-env": return ambientToken ?? generateToken();
|
|
1937
|
+
case "external-generated": return generateToken();
|
|
1938
|
+
case "profile": return readOrCreateProfileToken();
|
|
1939
|
+
}
|
|
1725
1940
|
})();
|
|
1726
|
-
async function
|
|
1727
|
-
let host;
|
|
1728
|
-
let port;
|
|
1729
|
-
let sidecar;
|
|
1941
|
+
async function setupConnection() {
|
|
1730
1942
|
if (externalMode) {
|
|
1731
|
-
host = externalHost
|
|
1732
|
-
port = externalPort
|
|
1943
|
+
const host = externalHost ?? "127.0.0.1";
|
|
1944
|
+
const port = externalPort ?? 41184;
|
|
1733
1945
|
process.stderr.write(`External mode: connecting to Joplin at ${host}:${port}\n`);
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
apiToken: joplinToken,
|
|
1739
|
-
syncTarget: syncTarget.orUndefined()
|
|
1740
|
-
});
|
|
1741
|
-
(await sidecar.resolvePort()).fold((err) => process.stderr.write(`Warning: Port resolution failed: ${err.message}\n`), (p) => process.stderr.write(`Sidecar will use port ${p}\n`));
|
|
1742
|
-
sidecar.start().then((result) => {
|
|
1743
|
-
result.fold((err) => {
|
|
1744
|
-
process.stderr.write(`Warning: Sidecar failed to start: ${err.message}\n`);
|
|
1745
|
-
process.stderr.write("Attempting to connect to existing Joplin instance...\n");
|
|
1746
|
-
}, () => {
|
|
1747
|
-
process.stderr.write("Joplin sidecar started successfully\n");
|
|
1748
|
-
});
|
|
1749
|
-
});
|
|
1750
|
-
host = sidecar.getHost();
|
|
1751
|
-
port = sidecar.getPort();
|
|
1752
|
-
const cleanup = async () => {
|
|
1753
|
-
await sidecar.stop();
|
|
1754
|
-
process.exit(0);
|
|
1946
|
+
return {
|
|
1947
|
+
host,
|
|
1948
|
+
port,
|
|
1949
|
+
sidecar: void 0
|
|
1755
1950
|
};
|
|
1756
|
-
process.on("SIGINT", () => void cleanup());
|
|
1757
|
-
process.on("SIGTERM", () => void cleanup());
|
|
1758
1951
|
}
|
|
1952
|
+
const sidecar = new JoplinSidecar({
|
|
1953
|
+
profileDir,
|
|
1954
|
+
apiPort: DEFAULT_API_PORT,
|
|
1955
|
+
apiToken: joplinToken,
|
|
1956
|
+
syncTarget: syncTarget.orUndefined(),
|
|
1957
|
+
version: "2.2.0"
|
|
1958
|
+
});
|
|
1959
|
+
(await sidecar.resolvePort()).fold((err) => process.stderr.write(`Warning: Port resolution failed: ${err.message}\n`), (p) => process.stderr.write(`Sidecar will use port ${p}\n`));
|
|
1960
|
+
sidecar.start().then((result) => {
|
|
1961
|
+
result.fold((err) => {
|
|
1962
|
+
process.stderr.write(`Warning: Sidecar failed to start: ${err.message}\n`);
|
|
1963
|
+
process.stderr.write("Attempting to connect to existing Joplin instance...\n");
|
|
1964
|
+
}, () => {
|
|
1965
|
+
process.stderr.write("Joplin sidecar started successfully\n");
|
|
1966
|
+
});
|
|
1967
|
+
});
|
|
1968
|
+
const cleanup = async () => {
|
|
1969
|
+
await sidecar.stop();
|
|
1970
|
+
process.exit(0);
|
|
1971
|
+
};
|
|
1972
|
+
process.on("SIGINT", () => void cleanup());
|
|
1973
|
+
process.on("SIGTERM", () => void cleanup());
|
|
1974
|
+
return {
|
|
1975
|
+
host: sidecar.getHost(),
|
|
1976
|
+
port: sidecar.getPort(),
|
|
1977
|
+
sidecar
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
async function main() {
|
|
1981
|
+
const { host, port, sidecar } = await setupConnection();
|
|
1759
1982
|
if (isHttpMode) {
|
|
1760
1983
|
process.stderr.write("Starting HTTP transport mode with FastMCP...\n");
|
|
1761
1984
|
await startFastMCPServer({
|
|
@@ -1783,7 +2006,7 @@ async function startStdioServer(host, port, token, sidecar) {
|
|
|
1783
2006
|
});
|
|
1784
2007
|
const server = new Server({
|
|
1785
2008
|
name: "joplin-mcp-server",
|
|
1786
|
-
version: "2.
|
|
2009
|
+
version: "2.2.0"
|
|
1787
2010
|
}, { capabilities: {
|
|
1788
2011
|
resources: {},
|
|
1789
2012
|
tools: {},
|
|
@@ -2015,7 +2238,7 @@ async function startStdioServer(host, port, token, sidecar) {
|
|
|
2015
2238
|
});
|
|
2016
2239
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2017
2240
|
const toolName = request.params.name;
|
|
2018
|
-
const args = request.params.arguments
|
|
2241
|
+
const args = request.params.arguments ?? {};
|
|
2019
2242
|
try {
|
|
2020
2243
|
switch (toolName) {
|
|
2021
2244
|
case "list_notebooks": return {
|
|
@@ -2105,10 +2328,11 @@ async function startStdioServer(host, port, token, sidecar) {
|
|
|
2105
2328
|
default: throw new Error(`Unknown tool: ${toolName}`);
|
|
2106
2329
|
}
|
|
2107
2330
|
} catch (error) {
|
|
2331
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2108
2332
|
return {
|
|
2109
2333
|
content: [{
|
|
2110
2334
|
type: "text",
|
|
2111
|
-
text:
|
|
2335
|
+
text: error instanceof ToolError ? errorMessage : `Error: ${errorMessage}`
|
|
2112
2336
|
}],
|
|
2113
2337
|
isError: true
|
|
2114
2338
|
};
|
|
@@ -2132,8 +2356,8 @@ async function startStdioServer(host, port, token, sidecar) {
|
|
|
2132
2356
|
direction: "RESPONSE",
|
|
2133
2357
|
message
|
|
2134
2358
|
};
|
|
2135
|
-
fs.appendFileSync(logFile, JSON.stringify(logEntry)
|
|
2136
|
-
|
|
2359
|
+
fs.appendFileSync(logFile, `${JSON.stringify(logEntry)}\n`);
|
|
2360
|
+
await Object.getPrototypeOf(Object.getPrototypeOf(this)).sendMessage.call(this, message);
|
|
2137
2361
|
}
|
|
2138
2362
|
async handleMessage(message) {
|
|
2139
2363
|
this.commandCounter++;
|
|
@@ -2143,8 +2367,8 @@ async function startStdioServer(host, port, token, sidecar) {
|
|
|
2143
2367
|
commandNumber: this.commandCounter,
|
|
2144
2368
|
message
|
|
2145
2369
|
};
|
|
2146
|
-
fs.appendFileSync(logFile, JSON.stringify(logEntry)
|
|
2147
|
-
|
|
2370
|
+
fs.appendFileSync(logFile, `${JSON.stringify(logEntry)}\n`);
|
|
2371
|
+
await Object.getPrototypeOf(Object.getPrototypeOf(this)).handleMessage.call(this, message);
|
|
2148
2372
|
}
|
|
2149
2373
|
}
|
|
2150
2374
|
const stdioTransport = new LoggingTransport();
|
|
@@ -2156,7 +2380,7 @@ async function startStdioServer(host, port, token, sidecar) {
|
|
|
2156
2380
|
process.exit(1);
|
|
2157
2381
|
}
|
|
2158
2382
|
}
|
|
2159
|
-
|
|
2160
2383
|
//#endregion
|
|
2161
|
-
export {
|
|
2384
|
+
export {};
|
|
2385
|
+
|
|
2162
2386
|
//# sourceMappingURL=index.js.map
|