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/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 { Either, Left, Match, Option, Right } from "functype";
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 = process.platform === "win32";
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 ((e === null || e === void 0 || (_e$cause = e.cause) === null || _e$cause === void 0 ? void 0 : _e$cause.code) === "ECONNREFUSED") return true;
43
- if (e === null || e === void 0 || (_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 === null || inner === void 0 ? void 0 : inner.code) === "ECONNREFUSED")) return true;
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
- let desktopDetected = false;
62
- for (let port = startPort; port < startPort + MAX_PORT_ATTEMPTS; port++) {
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
- desktopDetected = true;
76
- process.stderr.write(`[joplin-sidecar] Port ${port}: Joplin Desktop detected (different token), skipping\n`);
77
- } else process.stderr.write(`[joplin-sidecar] Port ${port} occupied (${status}), trying next...\n`);
78
- }
79
- return { outcome: "exhausted" };
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 localBin = join(process.cwd(), "node_modules", ".bin", isWindows ? "joplin.cmd" : "joplin");
88
- if (fs.existsSync(localBin)) return Right(localBin);
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
- await execFileAsync(cli.endsWith("npx") ? "npx" : cli, cli.endsWith("npx") ? [
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
- (_proc$stderr = proc.stderr) === null || _proc$stderr === void 0 || _proc$stderr.on("data", (data) => {
259
+ proc.stderr.on("data", (data) => {
195
260
  process.stderr.write(`[joplin-sidecar] ${data.toString()}`);
196
261
  });
197
- (_proc$stdout = proc.stdout) === null || _proc$stdout === void 0 || _proc$stdout.on("data", (data) => {
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
- let procExitCode = null;
275
+ const procExitState = { code: null };
211
276
  if (proc) proc.once("exit", (code) => {
212
- procExitCode = code;
277
+ procExitState.code = code;
213
278
  });
214
- for (let i = 0; i < maxRetries; i++) {
215
- if (Date.now() > deadline) break;
216
- if (procExitCode !== null) return Left(sidecarError("SPAWN_FAILED", `Joplin server process exited unexpectedly (code ${procExitCode}). Port ${port} may already be in use by another Joplin instance or process.`));
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
- return Left(sidecarError("HEALTH_CHECK_FAILED", "Joplin server did not become ready within 60s"));
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(os.homedir(), ".config", "joplin-mcp"),
269
- apiPort: config.apiPort ?? DEFAULT_API_PORT,
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 with matching token on port ${this.config.apiPort}, reusing\n`);
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 (!isWsl) return linuxPath;
510
+ if (!Platform.isWSL()) return linuxPath;
387
511
  if (fs.existsSync(linuxPath) && isNonEmptyDir(linuxPath)) return linuxPath;
388
- try {
389
- const usersDir = "/mnt/c/Users";
390
- const users = fs.readdirSync(usersDir).filter((u) => ![
391
- "Public",
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
- if (expanded.startsWith("~/") || expanded === "~") return resolveWslPath(expanded.replace("~", os.homedir()), expanded.slice(2));
409
- return expanded;
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").fold(() => {}, (token) => {
493
- process.env.JOPLIN_TOKEN = token;
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
- transport = value;
501
- });
502
- extractArg(args, "--http-port").fold(() => {}, (value) => {
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
- httpPort = parsed;
509
- });
510
- const profileDir = extractArg(args, "--profile").or(Option(process.env.JOPLIN_PROFILE)).map(expandPath).orElse(`${os.homedir()}/.config/joplin-mcp`);
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: extractArg(args, "--sync-target").or(Option(process.env.JOPLIN_SYNC_TARGET)),
513
- syncPath: extractArg(args, "--sync-path").or(Option(process.env.JOPLIN_SYNC_PATH)).map(expandPath),
514
- syncUsername: extractArg(args, "--sync-username").or(Option(process.env.JOPLIN_SYNC_USERNAME)),
515
- syncPassword: extractArg(args, "--sync-password").or(Option(process.env.JOPLIN_SYNC_PASSWORD))
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 Joplin API token (required)
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/joplin-api-client.ts
606
- var JoplinAPIClient = class {
607
- baseURL;
608
- token;
609
- constructor({ host = "127.0.0.1", port = 41184, token }) {
610
- this.baseURL = `http://${host}:${port}`;
611
- this.token = token;
612
- }
613
- async serviceAvailable() {
614
- try {
615
- const response = await axios.get(`${this.baseURL}/ping`, { timeout: 5e3 });
616
- if (response.status === 200 && response.data === "JoplinClipperServer") return Right(true);
617
- return Left(/* @__PURE__ */ new Error("Unexpected response from Joplin ping"));
618
- } catch (error) {
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
- return `Error ${context.toLowerCase()}: ${error.message || "Unknown error"}`;
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
- if (!createdFolder || typeof createdFolder !== "object" || !createdFolder.id) return "Error: Unexpected response format from Joplin API when creating folder";
746
- let parentInfo = "Top level";
747
- if (createdFolder.parent_id) try {
748
- const parentNotebook = this.unwrap(await this.apiClient.get(`/folders/${createdFolder.parent_id}`, { query: { fields: "id,title" } }));
749
- if (parentNotebook && parentNotebook.title) parentInfo = `Inside "${parentNotebook.title}" (notebook_id: "${createdFolder.parent_id}")`;
750
- } catch {
751
- parentInfo = `Parent notebook ID: ${createdFolder.parent_id}`;
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.response) {
770
- if (error.response.status === 400) {
771
- var _error$response$data;
772
- return `Error creating notebook: Invalid request data.\n\nPlease check your input parameters. ${((_error$response$data = error.response.data) === null || _error$response$data === void 0 ? void 0 : _error$response$data.error) || ""}`;
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 (error.response.status === 404 && options.parent_id) return `Error: Parent notebook with ID "${options.parent_id}" not found.\n\nUse list_notebooks to see available notebooks and their IDs, or omit parent_id to create a top-level notebook.`;
775
- if (error.response.status === 409) return `Error: A notebook with the title "${options.title}" might already exist in this location.\n\nTry a different title or check existing notebooks with list_notebooks.`;
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
- return this.formatError(error, "creating notebook");
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
- if (!createdNote || typeof createdNote !== "object" || !createdNote.id) return "Error: Unexpected response format from Joplin API when creating note";
799
- let notebookInfo = "Root level";
800
- if (createdNote.parent_id) try {
801
- const notebook = this.unwrap(await this.apiClient.get(`/folders/${createdNote.parent_id}`, { query: { fields: "id,title" } }));
802
- if (notebook && notebook.title) notebookInfo = `"${notebook.title}" (notebook_id: "${createdNote.parent_id}")`;
803
- } catch {
804
- notebookInfo = `Notebook ID: ${createdNote.parent_id}`;
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.response) {
824
- if (error.response.status === 400) {
825
- var _error$response$data;
826
- return `Error creating note: Invalid request data.\n\nPlease check your input parameters. ${((_error$response$data = error.response.data) === null || _error$response$data === void 0 ? void 0 : _error$response$data.error) || ""}`;
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 (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.`;
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
- return this.formatError(error, "creating note");
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
- if (!folderToDelete || !folderToDelete.id) return `Folder with ID "${options.folder_id}" not found.\n\nUse list_notebooks to see available folders and their IDs.`;
848
- 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) => {
849
- var _response$items;
850
- return { items: ((_response$items = response.items) === null || _response$items === void 0 ? void 0 : _response$items.filter((folder) => folder.parent_id === options.folder_id)) || [] };
851
- }))]);
852
- const noteCount = ((_notes$items = notes.items) === null || _notes$items === void 0 ? void 0 : _notes$items.length) || 0;
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 || "Untitled"}`);
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
- let parentInfo = "Top level";
887
- if (folderToDelete.parent_id) try {
888
- const parentFolder = this.unwrap(await this.apiClient.get(`/folders/${folderToDelete.parent_id}`, { query: { fields: "title" } }));
889
- if (parentFolder === null || parentFolder === void 0 ? void 0 : parentFolder.title) parentInfo = `Inside "${parentFolder.title}" (notebook_id: "${folderToDelete.parent_id}")`;
890
- } catch {
891
- parentInfo = `Parent ID: ${folderToDelete.parent_id}`;
892
- }
893
- this.unwrap(await this.apiClient.delete(`/folders/${options.folder_id}`));
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.response) {
917
- if (error.response.status === 404) return `Folder with ID "${options.folder_id}" not found.\n\nUse list_notebooks to see available folders and their IDs.`;
918
- if (error.response.status === 403) return `Permission denied: Cannot delete folder with ID "${options.folder_id}".\n\nThis might be a protected system folder.`;
919
- if (error.response.status === 409) return `Cannot delete folder: It may contain items that prevent deletion.\n\nTry moving or deleting the contents first, or use force option.`;
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
- return this.formatError(error, "deleting folder");
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
- if (!noteToDelete || !noteToDelete.id) return `Note with ID "${options.note_id}" not found.\n\nUse search_notes to find notes and their IDs.`;
938
- let notebookInfo = "Root level";
939
- if (noteToDelete.parent_id) try {
940
- const notebook = this.unwrap(await this.apiClient.get(`/folders/${noteToDelete.parent_id}`, { query: { fields: "title" } }));
941
- if (notebook === null || notebook === void 0 ? void 0 : notebook.title) notebookInfo = `"${notebook.title}" (notebook_id: "${noteToDelete.parent_id}")`;
942
- } catch {
943
- notebookInfo = `Notebook ID: ${noteToDelete.parent_id}`;
944
- }
945
- this.unwrap(await this.apiClient.delete(`/notes/${options.note_id}`));
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.response) {
977
- if (error.response.status === 404) return `Note with ID "${options.note_id}" not found.\n\nUse search_notes to find notes and their IDs.`;
978
- if (error.response.status === 403) return `Permission denied: Cannot delete note with ID "${options.note_id}".\n\nThis might be a protected system note.`;
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
- return this.formatError(error, "deleting note");
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
- if (!currentFolder || !currentFolder.id) return `Folder with ID "${options.folder_id}" not found.\n\nUse list_notebooks to see available folders and their IDs.`;
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
- if (!updatedFolder || typeof updatedFolder !== "object" || !updatedFolder.id) return "Error: Unexpected response format from Joplin API when updating folder";
1007
- let oldParentInfo = "Top level";
1008
- let newParentInfo = "Top level";
1009
- if (currentFolder.parent_id) try {
1010
- const oldParent = this.unwrap(await this.apiClient.get(`/folders/${currentFolder.parent_id}`, { query: { fields: "title" } }));
1011
- if (oldParent === null || oldParent === void 0 ? void 0 : oldParent.title) oldParentInfo = `Inside "${oldParent.title}"`;
1012
- } catch {
1013
- oldParentInfo = `Parent ID: ${currentFolder.parent_id}`;
1014
- }
1015
- if (updatedFolder.parent_id && updatedFolder.parent_id !== currentFolder.parent_id) try {
1016
- const newParent = this.unwrap(await this.apiClient.get(`/folders/${updatedFolder.parent_id}`, { query: { fields: "title" } }));
1017
- if (newParent === null || newParent === void 0 ? void 0 : newParent.title) newParentInfo = `Inside "${newParent.title}"`;
1018
- } catch {
1019
- newParentInfo = `Parent ID: ${updatedFolder.parent_id}`;
1020
- }
1021
- else if (updatedFolder.parent_id) newParentInfo = oldParentInfo;
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.response) {
1043
- if (error.response.status === 404) {
1044
- var _error$config;
1045
- if ((_error$config = error.config) === null || _error$config === void 0 || (_error$config = _error$config.url) === null || _error$config === void 0 ? void 0 : _error$config.includes(`/folders/${options.folder_id}`)) return `Folder with ID "${options.folder_id}" not found.\n\nUse list_notebooks to see available folders and their IDs.`;
1046
- if (options.parent_id) return `Error: Parent folder with ID "${options.parent_id}" not found.\n\nUse list_notebooks to see available folders and their IDs.`;
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 (error.response.status === 400) {
1049
- var _error$response$data;
1050
- return `Error updating folder: Invalid request data.\n\nPlease check your input parameters. ${((_error$response$data = error.response.data) === null || _error$response$data === void 0 ? void 0 : _error$response$data.error) || ""}`;
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 (error.response.status === 409) return `Error: A folder with the title "${options.title}" might already exist in this location.\n\nTry a different title or check existing folders with list_notebooks.`;
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
- return this.formatError(error, "updating folder");
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
- if (!currentNote || !currentNote.id) return `Note with ID "${options.note_id}" not found.\n\nUse search_notes to find notes and their IDs.`;
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
- if (!updatedNote || typeof updatedNote !== "object" || !updatedNote.id) return "Error: Unexpected response format from Joplin API when updating note";
1092
- let oldNotebookInfo = "Root level";
1093
- let newNotebookInfo = "Root level";
1094
- if (currentNote.parent_id) try {
1095
- const oldNotebook = this.unwrap(await this.apiClient.get(`/folders/${currentNote.parent_id}`, { query: { fields: "title" } }));
1096
- if (oldNotebook === null || oldNotebook === void 0 ? void 0 : oldNotebook.title) oldNotebookInfo = `"${oldNotebook.title}"`;
1097
- } catch {
1098
- oldNotebookInfo = `Notebook ID: ${currentNote.parent_id}`;
1099
- }
1100
- if (updatedNote.parent_id && updatedNote.parent_id !== currentNote.parent_id) try {
1101
- const newNotebook = this.unwrap(await this.apiClient.get(`/folders/${updatedNote.parent_id}`, { query: { fields: "title" } }));
1102
- if (newNotebook === null || newNotebook === void 0 ? void 0 : newNotebook.title) newNotebookInfo = `"${newNotebook.title}"`;
1103
- } catch {
1104
- newNotebookInfo = `Notebook ID: ${updatedNote.parent_id}`;
1105
- }
1106
- else if (updatedNote.parent_id) newNotebookInfo = oldNotebookInfo;
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.response) {
1142
- if (error.response.status === 404) return `Note with ID "${options.note_id}" not found.\n\nUse search_notes to find notes and their IDs.`;
1143
- if (error.response.status === 400) {
1144
- var _error$response$data;
1145
- return `Error updating note: Invalid request data.\n\nPlease check your input parameters. ${((_error$response$data = error.response.data) === null || _error$response$data === void 0 ? void 0 : _error$response$data.error) || ""}`;
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
- return this.formatError(error, "updating note");
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
- return this.formatError(error, "listing notebooks");
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.id;
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 (let i = 0; i < noteIds.length; i++) {
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
- let notebookInfo = "Unknown notebook";
1228
- if (note.parent_id) try {
1229
- const notebook = this.unwrap(await this.apiClient.get(`/folders/${note.parent_id}`, { query: { fields: "id,title" } }));
1230
- if (notebook && notebook.title) notebookInfo = `"${notebook.title}" (notebook_id: "${note.parent_id}")`;
1231
- } catch (err) {
1232
- process.stderr.write(`Error fetching notebook info for note ${noteId}: ${err}\n`);
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
- if (error.response && error.response.status === 404) {
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: ${error.message || "Unknown error"}\n`);
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
- if (!note || typeof note !== "object" || !note.id) return `Error: Unexpected response format from Joplin API when fetching note`;
1287
- let notebookInfo = "Unknown notebook";
1288
- if (note.parent_id) try {
1289
- const notebook = this.unwrap(await this.apiClient.get(`/folders/${note.parent_id}`, { query: { fields: "id,title" } }));
1290
- if (notebook && notebook.title) notebookInfo = `"${notebook.title}" (notebook_id: "${note.parent_id}")`;
1291
- } catch (err) {
1292
- process.stderr.write(`Error fetching notebook info: ${err}\n`);
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
- if (error.response && error.response.status === 404) return `Note with ID "${noteId}" not found.\n\nThis might happen if:\n1. The ID is incorrect\n2. You're using a notebook ID instead of a note ID\n3. The note has been deleted\n\nUse search_notes to find notes and their IDs.`;
1320
- return this.formatError(error, "reading note") + `\n\nMake sure you're using a valid note ID.\nUse search_notes to find notes and their IDs.`;
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
- if (!notebook || typeof notebook !== "object" || !notebook.id) return `Error: Unexpected response format from Joplin API when fetching notebook`;
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
- if (!notes || typeof notes !== "object") return `Error: Unexpected response format from Joplin API when fetching notes`;
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
- if (error.response && error.response.status === 404) return `Notebook with ID "${notebookId}" not found.\n\nThis might happen if:\n1. The ID is incorrect\n2. You're using a note title instead of a notebook ID\n3. The notebook has been deleted\n\nUse list_notebooks to see all available notebooks with their IDs.`;
1359
- return this.formatError(error, "reading notebook") + `\n\nMake sure you're using a valid notebook ID, not a note title.\nUse list_notebooks to see all available notebooks with their IDs.`;
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 || ""] || "Unknown notebook";
1391
- const notebookId = note.parent_id || "unknown";
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
- return this.formatError(error, "searching notes");
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 "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;
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: async () => {
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: async (args) => {
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: async (args) => {
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: async (args) => {
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: async (args) => {
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: async (args) => {
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: async (args) => {
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: async (args) => {
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: async (args) => {
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: async (args) => {
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: async (args) => {
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: async () => {
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 || 3e3;
1689
- const endpoint = options.endpoint || "/mcp";
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 = !!(externalHost || externalPort);
1707
- if (!process.env.JOPLIN_TOKEN && externalMode) {
1708
- process.stderr.write("Error: JOPLIN_TOKEN is required in external mode. Use --token <token> or set JOPLIN_TOKEN environment variable.\n");
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 joplinToken = (() => {
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 = `mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 32)}`;
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 main() {
1727
- let host;
1728
- let port;
1729
- let sidecar;
1941
+ async function setupConnection() {
1730
1942
  if (externalMode) {
1731
- host = externalHost || "127.0.0.1";
1732
- port = externalPort || DEFAULT_API_PORT;
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
- } else {
1735
- sidecar = new JoplinSidecar({
1736
- profileDir,
1737
- apiPort: DEFAULT_API_PORT,
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.1.0"
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: `Error: ${error instanceof Error ? error.message : String(error)}`
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) + "\n");
2136
- return Object.getPrototypeOf(Object.getPrototypeOf(this)).sendMessage.call(this, message);
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) + "\n");
2147
- return Object.getPrototypeOf(Object.getPrototypeOf(this)).handleMessage.call(this, message);
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