joplin-mcp-server 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.d.ts CHANGED
@@ -1 +1 @@
1
- export { };
1
+ export {}
package/dist/bin.js CHANGED
@@ -3,12 +3,12 @@
3
3
  process.env.TRANSPORT_TYPE ??= "stdio";
4
4
  const args = process.argv.slice(2);
5
5
  if (args.includes("--version") || args.includes("-v")) {
6
- console.log("2.1.1");
6
+ console.log("2.2.1");
7
7
  process.exit(0);
8
8
  }
9
9
  if (args.includes("--help") || args.includes("-h")) {
10
10
  console.log(`
11
- Joplin MCP Server v2.1.1
11
+ Joplin MCP Server v2.2.1
12
12
 
13
13
  Usage: joplin-mcp-server [options]
14
14
 
@@ -17,7 +17,7 @@ Options:
17
17
  -h, --help Show help
18
18
 
19
19
  Environment Variables:
20
- JOPLIN_TOKEN Joplin API token (required)
20
+ JOPLIN_TOKEN API token for external mode; ignored in sidecar mode
21
21
  JOPLIN_HOST Connect to existing Joplin at this host (skips sidecar)
22
22
  JOPLIN_PORT Connect to existing Joplin on this port (skips sidecar)
23
23
  JOPLIN_CLI Path to joplin CLI binary (overrides auto-detection)
package/dist/bin.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"bin.js","names":[],"sources":["../src/bin.ts"],"sourcesContent":["#!/usr/bin/env node\n\n// Version is injected at build time via tsdown.config.ts define\ndeclare const __VERSION__: string\n\n// Force stdio mode for CLI/npx usage (unless explicitly overridden)\nprocess.env.TRANSPORT_TYPE ??= \"stdio\"\n\n// Handle command line arguments BEFORE any other imports\nconst args = process.argv.slice(2)\n\nif (args.includes(\"--version\") || args.includes(\"-v\")) {\n console.log(__VERSION__)\n process.exit(0)\n}\n\nif (args.includes(\"--help\") || args.includes(\"-h\")) {\n console.log(`\nJoplin MCP Server v${__VERSION__}\n\nUsage: joplin-mcp-server [options]\n\nOptions:\n -v, --version Show version number\n -h, --help Show help\n\nEnvironment Variables:\n JOPLIN_TOKEN Joplin API token (required)\n JOPLIN_HOST Connect to existing Joplin at this host (skips sidecar)\n JOPLIN_PORT Connect to existing Joplin on this port (skips sidecar)\n JOPLIN_CLI Path to joplin CLI binary (overrides auto-detection)\n\nFor more information, visit: https://github.com/jordanburke/joplin-mcp-server\n`)\n process.exit(0)\n}\n\n// Import and start server if not showing version/help\nasync function main() {\n // Import and run the main function from the FastMCP server\n await import(\"./index.js\")\n}\n\nvoid main()\n"],"mappings":";;AAMA,QAAQ,IAAI,mBAAmB;AAG/B,MAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;AAEjC,IAAI,KAAK,SAAS,WAAW,KAAK,KAAK,SAAS,IAAI,GAAG;CACrD,QAAQ,IAAA,OAAe;CACvB,QAAQ,KAAK,CAAC;AAChB;AAEA,IAAI,KAAK,SAAS,QAAQ,KAAK,KAAK,SAAS,IAAI,GAAG;CAClD,QAAQ,IAAI;;;;;;;;;;;;;;;;CAgBb;CACC,QAAQ,KAAK,CAAC;AAChB;AAGA,eAAe,OAAO;CAEpB,MAAM,OAAO;AACf;AAEK,KAAK"}
1
+ {"version":3,"file":"bin.js","names":[],"sources":["../src/bin.ts"],"sourcesContent":["#!/usr/bin/env node\n\n// Version is injected at build time via tsdown.config.ts define\ndeclare const __VERSION__: string\n\n// Force stdio mode for CLI/npx usage (unless explicitly overridden)\nprocess.env.TRANSPORT_TYPE ??= \"stdio\"\n\n// Handle command line arguments BEFORE any other imports\nconst args = process.argv.slice(2)\n\nif (args.includes(\"--version\") || args.includes(\"-v\")) {\n console.log(__VERSION__)\n process.exit(0)\n}\n\nif (args.includes(\"--help\") || args.includes(\"-h\")) {\n console.log(`\nJoplin MCP Server v${__VERSION__}\n\nUsage: joplin-mcp-server [options]\n\nOptions:\n -v, --version Show version number\n -h, --help Show help\n\nEnvironment Variables:\n JOPLIN_TOKEN API token for external mode; ignored in sidecar mode\n JOPLIN_HOST Connect to existing Joplin at this host (skips sidecar)\n JOPLIN_PORT Connect to existing Joplin on this port (skips sidecar)\n JOPLIN_CLI Path to joplin CLI binary (overrides auto-detection)\n\nFor more information, visit: https://github.com/jordanburke/joplin-mcp-server\n`)\n process.exit(0)\n}\n\n// Import and start server if not showing version/help\nasync function main() {\n // Import and run the main function from the FastMCP server\n await import(\"./index.js\")\n}\n\nvoid main()\n"],"mappings":";;AAMA,QAAQ,IAAI,mBAAmB;AAG/B,MAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;AAEjC,IAAI,KAAK,SAAS,WAAW,KAAK,KAAK,SAAS,IAAI,GAAG;CACrD,QAAQ,IAAA,OAAe;CACvB,QAAQ,KAAK,CAAC;AAChB;AAEA,IAAI,KAAK,SAAS,QAAQ,KAAK,KAAK,SAAS,IAAI,GAAG;CAClD,QAAQ,IAAI;;;;;;;;;;;;;;;;CAgBb;CACC,QAAQ,KAAK,CAAC;AAChB;AAGA,eAAe,OAAO;CAEpB,MAAM,OAAO;AACf;AAEK,KAAK"}
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { };
1
+ export {}
package/dist/index.js CHANGED
@@ -3,11 +3,11 @@ 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
11
  import { Path, Platform } from "functype-os";
12
12
  import { promisify } from "util";
13
13
  import axios from "axios";
@@ -35,6 +35,52 @@ const fetchWithTimeout = async (url, timeoutMs = 5e3) => {
35
35
  clearTimeout(timer);
36
36
  }
37
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
+ };
38
84
  const isConnectionRefused = (err) => {
39
85
  var _e$cause, _e$cause2;
40
86
  const e = err;
@@ -42,11 +88,11 @@ const isConnectionRefused = (err) => {
42
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;
43
89
  return false;
44
90
  };
45
- const probePort = async (port, token) => {
91
+ const probePort = async (port, token, profileDir, identity) => {
46
92
  try {
47
93
  if (await (await fetchWithTimeout(`http://127.0.0.1:${port}/ping`, 3e3)).text() !== "JoplinClipperServer") return "occupied_other";
48
94
  try {
49
- 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");
50
96
  return "joplin_foreign";
51
97
  } catch {
52
98
  return "joplin_foreign";
@@ -56,10 +102,10 @@ const probePort = async (port, token) => {
56
102
  return "occupied_unresponsive";
57
103
  }
58
104
  };
59
- const resolveAvailablePort = async (startPort, token) => {
105
+ const resolveAvailablePort = async (startPort, token, profileDir, identity) => {
60
106
  const tryPort = async (port, desktopDetected) => {
61
107
  if (port >= startPort + MAX_PORT_ATTEMPTS) return { outcome: "exhausted" };
62
- const status = await probePort(port, token);
108
+ const status = await probePort(port, token, profileDir, identity);
63
109
  if (status === "free") return {
64
110
  outcome: "free",
65
111
  port,
@@ -70,8 +116,17 @@ const resolveAvailablePort = async (startPort, token) => {
70
116
  port,
71
117
  desktopDetected
72
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
+ }
73
128
  if (status === "joplin_foreign") {
74
- process.stderr.write(`[joplin-sidecar] Port ${port}: Joplin Desktop detected (different token), skipping\n`);
129
+ process.stderr.write(`[joplin-sidecar] Port ${port}: another Joplin instance with a different token (possibly Joplin Desktop), skipping\n`);
75
130
  return tryPort(port + 1, true);
76
131
  }
77
132
  process.stderr.write(`[joplin-sidecar] Port ${port} occupied (${status}), trying next...\n`);
@@ -79,14 +134,22 @@ const resolveAvailablePort = async (startPort, token) => {
79
134
  };
80
135
  return tryPort(startPort, false);
81
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));
82
145
  const findJoplinCli = async () => {
83
146
  const envCli = process.env.JOPLIN_CLI;
84
147
  if (envCli) {
85
148
  if (fs.existsSync(envCli)) return Right(envCli);
86
149
  return Left(sidecarError("CLI_NOT_FOUND", `JOPLIN_CLI path not found: ${envCli}`));
87
150
  }
88
- const localBin = join(process.cwd(), "node_modules", ".bin", isWindows ? "joplin.cmd" : "joplin");
89
- if (fs.existsSync(localBin)) return Right(localBin);
151
+ const bundled = localCliCandidates().find((candidate) => fs.existsSync(candidate));
152
+ if (bundled) return Right(bundled);
90
153
  try {
91
154
  const { stdout } = await execAsync(`${whichCmd} joplin`, {
92
155
  encoding: "utf-8",
@@ -140,25 +203,51 @@ const buildSettingsRecord = (config) => {
140
203
  return settings;
141
204
  };
142
205
  const runJoplinConfig = async (cli, profileDir, key, value) => {
143
- await execFileAsync(cli.endsWith("npx") ? "npx" : cli, cli.endsWith("npx") ? [
206
+ const cmd = cli.endsWith("npx") ? "npx" : cli;
207
+ const args = cli.endsWith("npx") ? [
144
208
  "joplin",
145
- "config",
146
209
  "--profile",
147
210
  profileDir,
211
+ "config",
148
212
  key,
149
213
  value
150
214
  ] : [
151
- "config",
152
215
  "--profile",
153
216
  profileDir,
217
+ "config",
154
218
  key,
155
219
  value
156
- ], {
220
+ ];
221
+ await execFileAsync(cmd, args, {
157
222
  encoding: "utf-8",
158
223
  timeout: 3e4,
159
224
  shell: isWindows
160
225
  });
161
226
  };
227
+ const runJoplinSync = async (cli, profileDir) => {
228
+ const cmd = cli.endsWith("npx") ? "npx" : cli;
229
+ const args = cli.endsWith("npx") ? [
230
+ "joplin",
231
+ "--profile",
232
+ profileDir,
233
+ "sync"
234
+ ] : [
235
+ "--profile",
236
+ profileDir,
237
+ "sync"
238
+ ];
239
+ const { stdout } = await execFileAsync(cmd, args, {
240
+ encoding: "utf-8",
241
+ timeout: 3e5,
242
+ shell: isWindows
243
+ });
244
+ return stdout;
245
+ };
246
+ const summarizeSyncOutput = (stdout) => {
247
+ const lines = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
248
+ const completed = lines.filter((l) => l.startsWith("Created") || l.startsWith("Fetched") || l.includes("Completed"));
249
+ return (completed.length > 0 ? completed[completed.length - 1] : lines[lines.length - 1]) ?? "Sync completed.";
250
+ };
162
251
  const configureJoplin = async (cli, config) => {
163
252
  const settings = buildSettingsRecord(config);
164
253
  try {
@@ -173,15 +262,15 @@ const spawnServer = (cli, config) => {
173
262
  try {
174
263
  const proc = spawn(cli.endsWith("npx") ? "npx" : cli, cli.endsWith("npx") ? [
175
264
  "joplin",
176
- "server",
177
- "start",
178
265
  "--profile",
179
- config.profileDir
180
- ] : [
266
+ config.profileDir,
181
267
  "server",
182
- "start",
268
+ "start"
269
+ ] : [
183
270
  "--profile",
184
- config.profileDir
271
+ config.profileDir,
272
+ "server",
273
+ "start"
185
274
  ], {
186
275
  stdio: [
187
276
  "ignore",
@@ -264,17 +353,70 @@ var JoplinSidecar = class {
264
353
  childProcess = null;
265
354
  startPromise = null;
266
355
  portResolution = null;
356
+ cleanupRegistered = false;
267
357
  constructor(config) {
268
358
  this.config = {
269
359
  profileDir: config.profileDir ?? join(Platform.homeDir(), ".config", "joplin-mcp"),
270
360
  apiPort: config.apiPort ?? 41184,
271
361
  apiToken: config.apiToken,
272
362
  syncTarget: config.syncTarget,
273
- syncInterval: config.syncInterval
363
+ syncInterval: config.syncInterval,
364
+ version: config.version
274
365
  };
275
366
  }
367
+ identity() {
368
+ return computeIdentityHash(this.config);
369
+ }
370
+ sidecarAlive() {
371
+ return servesOurProfile(this.config.profileDir);
372
+ }
373
+ async replaceStaleServer(port) {
374
+ const previous = readSidecarStamp(this.config.profileDir).fold(() => "unstamped", (s) => `v${s.version}`);
375
+ process.stderr.write(`[joplin-sidecar] Replacing sidecar on port ${port} (${previous} -> v${this.config.version ?? "unknown"} or changed sync config)\n`);
376
+ this.reapRecordedServer();
377
+ await this.waitForPortFree(port);
378
+ }
379
+ async waitForPortFree(port, maxAttempts = 20) {
380
+ const attempt = async (i) => {
381
+ if (i >= maxAttempts) {
382
+ process.stderr.write(`[joplin-sidecar] Port ${port} still busy after teardown, continuing anyway\n`);
383
+ return;
384
+ }
385
+ try {
386
+ await fetchWithTimeout(`http://127.0.0.1:${port}/ping`, 1e3);
387
+ } catch (err) {
388
+ if (isConnectionRefused(err)) return;
389
+ }
390
+ await new Promise((resolve) => setTimeout(resolve, 250));
391
+ return attempt(i + 1);
392
+ };
393
+ return attempt(0);
394
+ }
395
+ killChildSync() {
396
+ if (!this.childProcess) return;
397
+ try {
398
+ this.childProcess.kill("SIGTERM");
399
+ } catch {}
400
+ this.reapRecordedServer();
401
+ }
402
+ reapRecordedServer() {
403
+ readProfileServerPid(this.config.profileDir).fold(() => void 0, (pid) => {
404
+ if (pid !== process.pid && isProcessAlive(pid)) try {
405
+ process.kill(pid, "SIGTERM");
406
+ } catch {}
407
+ });
408
+ }
409
+ registerCleanup() {
410
+ if (this.cleanupRegistered) return;
411
+ this.cleanupRegistered = true;
412
+ process.once("exit", () => this.killChildSync());
413
+ process.once("SIGHUP", () => {
414
+ this.killChildSync();
415
+ process.exit(0);
416
+ });
417
+ }
276
418
  async resolvePort() {
277
- const resolution = await resolveAvailablePort(this.config.apiPort, this.config.apiToken);
419
+ const resolution = await resolveAvailablePort(this.config.apiPort, this.config.apiToken, this.config.profileDir, this.identity());
278
420
  this.portResolution = resolution;
279
421
  if (resolution.outcome === "exhausted") {
280
422
  const lastPort = this.config.apiPort + MAX_PORT_ATTEMPTS - 1;
@@ -289,12 +431,18 @@ var JoplinSidecar = class {
289
431
  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);
290
432
  }
291
433
  async start() {
434
+ if (this.startPromise && !this.sidecarAlive()) {
435
+ process.stderr.write("[joplin-sidecar] Sidecar is no longer running, restarting\n");
436
+ this.startPromise = null;
437
+ this.childProcess = null;
438
+ this.portResolution = null;
439
+ }
292
440
  if (this.startPromise) return this.startPromise;
293
441
  this.startPromise = this.doStart();
294
442
  return this.startPromise;
295
443
  }
296
444
  async doStart() {
297
- var _this$portResolution3;
445
+ var _this$portResolution3, _this$portResolution4;
298
446
  const cliResult = await findJoplinCli();
299
447
  if (Either.isLeft(cliResult)) return Left(cliResult.fold((e) => e, () => null));
300
448
  const cli = cliResult.fold(() => "", (v) => v);
@@ -304,9 +452,10 @@ var JoplinSidecar = class {
304
452
  if (Either.isLeft(portResult)) return Left(portResult.fold((e) => e, () => null));
305
453
  }
306
454
  if (((_this$portResolution3 = this.portResolution) === null || _this$portResolution3 === void 0 ? void 0 : _this$portResolution3.outcome) === "reuse_existing") {
307
- process.stderr.write(`[joplin-sidecar] Existing Joplin server with matching token on port ${this.config.apiPort}, reusing\n`);
455
+ process.stderr.write(`[joplin-sidecar] Existing Joplin server for this profile on port ${this.config.apiPort}, reusing\n`);
308
456
  return Right(this.childProcess ?? null);
309
457
  }
458
+ if (((_this$portResolution4 = this.portResolution) === null || _this$portResolution4 === void 0 ? void 0 : _this$portResolution4.outcome) === "replace_stale") await this.replaceStaleServer(this.config.apiPort);
310
459
  const configHash = computeConfigHash(this.config);
311
460
  if (isConfigCached(this.config.profileDir, configHash)) process.stderr.write("[joplin-sidecar] Configuration cached, skipping config step\n");
312
461
  else {
@@ -320,10 +469,15 @@ var JoplinSidecar = class {
320
469
  if (Either.isLeft(spawnResult)) return Left(spawnResult.fold((e) => e, () => null));
321
470
  const proc = spawnResult.fold(() => null, (v) => v);
322
471
  this.childProcess = proc;
472
+ this.registerCleanup();
323
473
  process.stderr.write(`[joplin-sidecar] Server spawned (pid: ${proc.pid})\n`);
324
474
  } else process.stderr.write(`[joplin-sidecar] Server already spawned (pid: ${this.childProcess.pid}), waiting for ready\n`);
325
475
  const readyResult = await waitForReady(this.config.apiPort, this.config.apiToken, this.childProcess);
326
476
  if (Either.isLeft(readyResult)) return Left(readyResult.fold((e) => e, () => null));
477
+ writeSidecarStamp(this.config.profileDir, {
478
+ version: this.config.version ?? "unknown",
479
+ identity: this.identity()
480
+ });
327
481
  return Right(this.childProcess);
328
482
  }
329
483
  async stop() {
@@ -342,6 +496,7 @@ var JoplinSidecar = class {
342
496
  resolve();
343
497
  });
344
498
  });
499
+ this.reapRecordedServer();
345
500
  this.childProcess = null;
346
501
  process.stderr.write("[joplin-sidecar] Server stopped\n");
347
502
  return Right(true);
@@ -349,6 +504,45 @@ var JoplinSidecar = class {
349
504
  return Left(error instanceof Error ? error : new Error(String(error)));
350
505
  }
351
506
  }
507
+ async releaseProfile() {
508
+ this.startPromise = null;
509
+ this.portResolution = null;
510
+ if (this.childProcess) {
511
+ const proc = this.childProcess;
512
+ try {
513
+ proc.kill("SIGTERM");
514
+ await new Promise((resolve) => {
515
+ const timeout = setTimeout(() => {
516
+ proc.kill("SIGKILL");
517
+ resolve();
518
+ }, 5e3);
519
+ proc.on("exit", () => {
520
+ clearTimeout(timeout);
521
+ resolve();
522
+ });
523
+ });
524
+ } catch {}
525
+ this.childProcess = null;
526
+ }
527
+ this.reapRecordedServer();
528
+ await this.waitForPortFree(this.config.apiPort);
529
+ }
530
+ async sync() {
531
+ const cliResult = await findJoplinCli();
532
+ if (Either.isLeft(cliResult)) return Left(cliResult.fold((e) => e, () => null));
533
+ const cli = cliResult.fold(() => "", (v) => v);
534
+ await this.releaseProfile();
535
+ const syncResult = await (async () => {
536
+ try {
537
+ return Right(summarizeSyncOutput(await runJoplinSync(cli, this.config.profileDir)));
538
+ } catch (e) {
539
+ return Left(sidecarError("SYNC_FAILED", "joplin sync failed", e));
540
+ }
541
+ })();
542
+ const restart = await this.start();
543
+ if (Either.isRight(syncResult) && Either.isLeft(restart)) return Left(restart.fold((e) => sidecarError("SYNC_FAILED", `Sync completed but the server failed to restart: ${e.message}`, e), () => null));
544
+ return syncResult;
545
+ }
352
546
  async healthCheck() {
353
547
  try {
354
548
  const response = await fetchWithTimeout(`http://127.0.0.1:${this.config.apiPort}/ping`, 5e3);
@@ -386,7 +580,10 @@ const resolveWslPath = (linuxPath, relativeToHome) => {
386
580
  const expandPath = (p) => {
387
581
  const expanded = expandVars(p);
388
582
  const tildeExpanded = Path.expandTilde(expanded);
389
- if (expanded === "~" || expanded.startsWith("~/")) return resolveWslPath(tildeExpanded, expanded === "~" ? "" : expanded.slice(2));
583
+ if (expanded === "~" || expanded.startsWith("~/")) {
584
+ const relativeToHome = expanded === "~" ? "" : expanded.slice(2);
585
+ return resolveWslPath(tildeExpanded, relativeToHome);
586
+ }
390
587
  return tildeExpanded;
391
588
  };
392
589
  const extractArg = (args, flag) => {
@@ -468,9 +665,7 @@ function parseArgs() {
468
665
  }
469
666
  };
470
667
  extractArg(args, "--env-file").fold(() => loadEnvFile(".env"), (file) => loadEnvFile(resolve(process.cwd(), file)));
471
- extractArg(args, "--token").fold(() => {}, (token) => {
472
- process.env.JOPLIN_TOKEN = token;
473
- });
668
+ const explicitToken = extractArg(args, "--token");
474
669
  const transport = extractArg(args, "--transport").map((value) => {
475
670
  if (value !== "stdio" && value !== "http") {
476
671
  process.stderr.write("Error: --transport must be either 'stdio' or 'http'\n");
@@ -487,11 +682,15 @@ function parseArgs() {
487
682
  return parsed;
488
683
  }).orElse(3e3);
489
684
  const profileDir = extractArg(args, "--profile").or(Option(process.env.JOPLIN_PROFILE)).map(expandPath).orElse(expandPath("~/.config/joplin-mcp"));
685
+ const syncTarget = extractArg(args, "--sync-target").or(Option(process.env.JOPLIN_SYNC_TARGET));
686
+ const syncPath = extractArg(args, "--sync-path").or(Option(process.env.JOPLIN_SYNC_PATH)).map(expandPath);
687
+ const syncUsername = extractArg(args, "--sync-username").or(Option(process.env.JOPLIN_SYNC_USERNAME));
688
+ const syncPassword = extractArg(args, "--sync-password").or(Option(process.env.JOPLIN_SYNC_PASSWORD));
490
689
  const syncResult = buildSyncTarget({
491
- syncTarget: extractArg(args, "--sync-target").or(Option(process.env.JOPLIN_SYNC_TARGET)),
492
- syncPath: extractArg(args, "--sync-path").or(Option(process.env.JOPLIN_SYNC_PATH)).map(expandPath),
493
- syncUsername: extractArg(args, "--sync-username").or(Option(process.env.JOPLIN_SYNC_USERNAME)),
494
- syncPassword: extractArg(args, "--sync-password").or(Option(process.env.JOPLIN_SYNC_PASSWORD))
690
+ syncTarget,
691
+ syncPath,
692
+ syncUsername,
693
+ syncPassword
495
694
  });
496
695
  if (Either.isLeft(syncResult)) {
497
696
  const err = syncResult.fold((e) => e, () => "");
@@ -521,7 +720,8 @@ OPTIONS:
521
720
  --help, -h Show this help message
522
721
 
523
722
  ENVIRONMENT VARIABLES:
524
- JOPLIN_TOKEN Joplin API token (required)
723
+ JOPLIN_TOKEN API token for external mode (JOPLIN_HOST/JOPLIN_PORT).
724
+ Ignored in sidecar mode, which manages its own token.
525
725
  JOPLIN_HOST Connect to existing Joplin at this host (skips sidecar)
526
726
  JOPLIN_PORT Connect to existing Joplin on this port (skips sidecar)
527
727
  JOPLIN_CLI Path to joplin CLI binary (overrides auto-detection)
@@ -576,10 +776,28 @@ Find your Joplin token in: Tools > Options > Web Clipper
576
776
  transport,
577
777
  httpPort,
578
778
  profileDir,
579
- syncTarget: resolvedSyncTarget
779
+ syncTarget: resolvedSyncTarget,
780
+ explicitToken
580
781
  };
581
782
  }
582
783
  //#endregion
784
+ //#region src/lib/resolve-token.ts
785
+ const resolveTokenSource = (input) => {
786
+ if (input.explicitToken.isEmpty === false) return {
787
+ source: "explicit",
788
+ ambientIgnored: false
789
+ };
790
+ if (input.externalMode) return {
791
+ source: input.ambientToken.isEmpty === false ? "external-env" : "external-generated",
792
+ ambientIgnored: false
793
+ };
794
+ return {
795
+ source: "profile",
796
+ ambientIgnored: input.ambientToken.isEmpty === false
797
+ };
798
+ };
799
+ const externalTokenMissing = (input) => input.externalMode && input.explicitToken.isEmpty && input.ambientToken.isEmpty;
800
+ //#endregion
583
801
  //#region src/lib/tools/base-tool.ts
584
802
  /**
585
803
  * Thrown by tools to signal an operation failure that should surface to the
@@ -1569,9 +1787,15 @@ var JoplinServerManager = class {
1569
1787
  var _this$config$sidecar;
1570
1788
  await this.ensureConnected();
1571
1789
  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." : "";
1790
+ if (this.config.sidecar) {
1791
+ const result = await this.config.sidecar.sync();
1792
+ this.connected = false;
1793
+ await this.ensureConnected();
1794
+ return result.fold((error) => `Sync failed: ${error.message}${desktopWarning}`, (summary) => `Sync complete. ${summary}${desktopWarning}`);
1795
+ }
1572
1796
  return (await this.apiClient.post("/services/sync", { action: "start" })).fold((error) => {
1573
1797
  const msg = error.message || String(error);
1574
- 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}`;
1798
+ if (msg.includes("404") || msg.includes("No action API") || msg.includes("No such service")) return `On-demand sync is not available on this Joplin instance.${desktopWarning}`;
1575
1799
  return `Sync failed: ${msg}${desktopWarning}`;
1576
1800
  }, () => `Sync triggered successfully.${desktopWarning}`);
1577
1801
  }
@@ -1743,29 +1967,45 @@ async function startFastMCPServer(options) {
1743
1967
  }
1744
1968
  //#endregion
1745
1969
  //#region src/index.ts
1746
- const { transport, httpPort, profileDir, syncTarget } = parseArgs();
1970
+ const { transport, httpPort, profileDir, syncTarget, explicitToken } = parseArgs();
1747
1971
  const isHttpMode = transport === "http";
1748
1972
  const externalHost = process.env.JOPLIN_HOST !== void 0 && process.env.JOPLIN_HOST !== "" ? process.env.JOPLIN_HOST : void 0;
1749
1973
  const externalPort = process.env.JOPLIN_PORT !== void 0 && process.env.JOPLIN_PORT !== "" ? parseInt(process.env.JOPLIN_PORT, 10) : void 0;
1750
1974
  const externalMode = externalHost !== void 0 || externalPort !== void 0;
1751
- if (!process.env.JOPLIN_TOKEN && externalMode) {
1752
- process.stderr.write("Error: JOPLIN_TOKEN is required in external mode. Use --token <token> or set JOPLIN_TOKEN environment variable.\n");
1975
+ const generateToken = () => `mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 32)}`;
1976
+ const explicitTokenValue = explicitToken.orUndefined();
1977
+ const ambientToken = process.env.JOPLIN_TOKEN;
1978
+ const tokenInput = {
1979
+ externalMode,
1980
+ explicitToken,
1981
+ ambientToken: Option(ambientToken)
1982
+ };
1983
+ if (externalTokenMissing(tokenInput)) {
1984
+ process.stderr.write("Error: a token is required in external mode. Use --token <token> or set JOPLIN_TOKEN environment variable.\n");
1753
1985
  process.exit(1);
1754
1986
  }
1755
- const joplinToken = (() => {
1756
- if (process.env.JOPLIN_TOKEN) return process.env.JOPLIN_TOKEN;
1757
- if (externalMode) return `mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 32)}`;
1987
+ const readOrCreateProfileToken = () => {
1758
1988
  const tokenPath = path.join(profileDir, ".mcp-token");
1759
1989
  try {
1760
1990
  const saved = fs.readFileSync(tokenPath, "utf-8").trim();
1761
1991
  if (saved) return saved;
1762
1992
  } catch {}
1763
- const token = `mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 32)}`;
1993
+ const token = generateToken();
1764
1994
  try {
1765
1995
  fs.mkdirSync(profileDir, { recursive: true });
1766
1996
  fs.writeFileSync(tokenPath, token);
1767
1997
  } catch {}
1768
1998
  return token;
1999
+ };
2000
+ const tokenResolution = resolveTokenSource(tokenInput);
2001
+ 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");
2002
+ const joplinToken = (() => {
2003
+ switch (tokenResolution.source) {
2004
+ case "explicit": return explicitTokenValue ?? generateToken();
2005
+ case "external-env": return ambientToken ?? generateToken();
2006
+ case "external-generated": return generateToken();
2007
+ case "profile": return readOrCreateProfileToken();
2008
+ }
1769
2009
  })();
1770
2010
  async function setupConnection() {
1771
2011
  if (externalMode) {
@@ -1782,7 +2022,8 @@ async function setupConnection() {
1782
2022
  profileDir,
1783
2023
  apiPort: DEFAULT_API_PORT,
1784
2024
  apiToken: joplinToken,
1785
- syncTarget: syncTarget.orUndefined()
2025
+ syncTarget: syncTarget.orUndefined(),
2026
+ version: "2.2.1"
1786
2027
  });
1787
2028
  (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`));
1788
2029
  sidecar.start().then((result) => {
@@ -1834,7 +2075,7 @@ async function startStdioServer(host, port, token, sidecar) {
1834
2075
  });
1835
2076
  const server = new Server({
1836
2077
  name: "joplin-mcp-server",
1837
- version: "2.1.1"
2078
+ version: "2.2.1"
1838
2079
  }, { capabilities: {
1839
2080
  resources: {},
1840
2081
  tools: {},