joplin-mcp-server 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.d.ts 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.0");
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.0
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,20 +203,22 @@ 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
@@ -173,15 +238,15 @@ const spawnServer = (cli, config) => {
173
238
  try {
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",
@@ -264,17 +329,70 @@ var JoplinSidecar = class {
264
329
  childProcess = null;
265
330
  startPromise = null;
266
331
  portResolution = null;
332
+ cleanupRegistered = false;
267
333
  constructor(config) {
268
334
  this.config = {
269
335
  profileDir: config.profileDir ?? join(Platform.homeDir(), ".config", "joplin-mcp"),
270
336
  apiPort: config.apiPort ?? 41184,
271
337
  apiToken: config.apiToken,
272
338
  syncTarget: config.syncTarget,
273
- syncInterval: config.syncInterval
339
+ syncInterval: config.syncInterval,
340
+ version: config.version
274
341
  };
275
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
+ }
276
394
  async resolvePort() {
277
- 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());
278
396
  this.portResolution = resolution;
279
397
  if (resolution.outcome === "exhausted") {
280
398
  const lastPort = this.config.apiPort + MAX_PORT_ATTEMPTS - 1;
@@ -289,12 +407,18 @@ var JoplinSidecar = class {
289
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);
290
408
  }
291
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
+ }
292
416
  if (this.startPromise) return this.startPromise;
293
417
  this.startPromise = this.doStart();
294
418
  return this.startPromise;
295
419
  }
296
420
  async doStart() {
297
- var _this$portResolution3;
421
+ var _this$portResolution3, _this$portResolution4;
298
422
  const cliResult = await findJoplinCli();
299
423
  if (Either.isLeft(cliResult)) return Left(cliResult.fold((e) => e, () => null));
300
424
  const cli = cliResult.fold(() => "", (v) => v);
@@ -304,9 +428,10 @@ var JoplinSidecar = class {
304
428
  if (Either.isLeft(portResult)) return Left(portResult.fold((e) => e, () => null));
305
429
  }
306
430
  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`);
431
+ process.stderr.write(`[joplin-sidecar] Existing Joplin server for this profile on port ${this.config.apiPort}, reusing\n`);
308
432
  return Right(this.childProcess ?? null);
309
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);
310
435
  const configHash = computeConfigHash(this.config);
311
436
  if (isConfigCached(this.config.profileDir, configHash)) process.stderr.write("[joplin-sidecar] Configuration cached, skipping config step\n");
312
437
  else {
@@ -320,10 +445,15 @@ var JoplinSidecar = class {
320
445
  if (Either.isLeft(spawnResult)) return Left(spawnResult.fold((e) => e, () => null));
321
446
  const proc = spawnResult.fold(() => null, (v) => v);
322
447
  this.childProcess = proc;
448
+ this.registerCleanup();
323
449
  process.stderr.write(`[joplin-sidecar] Server spawned (pid: ${proc.pid})\n`);
324
450
  } else process.stderr.write(`[joplin-sidecar] Server already spawned (pid: ${this.childProcess.pid}), waiting for ready\n`);
325
451
  const readyResult = await waitForReady(this.config.apiPort, this.config.apiToken, this.childProcess);
326
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
+ });
327
457
  return Right(this.childProcess);
328
458
  }
329
459
  async stop() {
@@ -342,6 +472,7 @@ var JoplinSidecar = class {
342
472
  resolve();
343
473
  });
344
474
  });
475
+ this.reapRecordedServer();
345
476
  this.childProcess = null;
346
477
  process.stderr.write("[joplin-sidecar] Server stopped\n");
347
478
  return Right(true);
@@ -386,7 +517,10 @@ const resolveWslPath = (linuxPath, relativeToHome) => {
386
517
  const expandPath = (p) => {
387
518
  const expanded = expandVars(p);
388
519
  const tildeExpanded = Path.expandTilde(expanded);
389
- if (expanded === "~" || expanded.startsWith("~/")) return resolveWslPath(tildeExpanded, expanded === "~" ? "" : expanded.slice(2));
520
+ if (expanded === "~" || expanded.startsWith("~/")) {
521
+ const relativeToHome = expanded === "~" ? "" : expanded.slice(2);
522
+ return resolveWslPath(tildeExpanded, relativeToHome);
523
+ }
390
524
  return tildeExpanded;
391
525
  };
392
526
  const extractArg = (args, flag) => {
@@ -468,9 +602,7 @@ function parseArgs() {
468
602
  }
469
603
  };
470
604
  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
- });
605
+ const explicitToken = extractArg(args, "--token");
474
606
  const transport = extractArg(args, "--transport").map((value) => {
475
607
  if (value !== "stdio" && value !== "http") {
476
608
  process.stderr.write("Error: --transport must be either 'stdio' or 'http'\n");
@@ -487,11 +619,15 @@ function parseArgs() {
487
619
  return parsed;
488
620
  }).orElse(3e3);
489
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));
490
626
  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))
627
+ syncTarget,
628
+ syncPath,
629
+ syncUsername,
630
+ syncPassword
495
631
  });
496
632
  if (Either.isLeft(syncResult)) {
497
633
  const err = syncResult.fold((e) => e, () => "");
@@ -521,7 +657,8 @@ OPTIONS:
521
657
  --help, -h Show this help message
522
658
 
523
659
  ENVIRONMENT VARIABLES:
524
- 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.
525
662
  JOPLIN_HOST Connect to existing Joplin at this host (skips sidecar)
526
663
  JOPLIN_PORT Connect to existing Joplin on this port (skips sidecar)
527
664
  JOPLIN_CLI Path to joplin CLI binary (overrides auto-detection)
@@ -576,10 +713,28 @@ Find your Joplin token in: Tools > Options > Web Clipper
576
713
  transport,
577
714
  httpPort,
578
715
  profileDir,
579
- syncTarget: resolvedSyncTarget
716
+ syncTarget: resolvedSyncTarget,
717
+ explicitToken
580
718
  };
581
719
  }
582
720
  //#endregion
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
+ };
735
+ };
736
+ const externalTokenMissing = (input) => input.externalMode && input.explicitToken.isEmpty && input.ambientToken.isEmpty;
737
+ //#endregion
583
738
  //#region src/lib/tools/base-tool.ts
584
739
  /**
585
740
  * Thrown by tools to signal an operation failure that should surface to the
@@ -1743,29 +1898,45 @@ async function startFastMCPServer(options) {
1743
1898
  }
1744
1899
  //#endregion
1745
1900
  //#region src/index.ts
1746
- const { transport, httpPort, profileDir, syncTarget } = parseArgs();
1901
+ const { transport, httpPort, profileDir, syncTarget, explicitToken } = parseArgs();
1747
1902
  const isHttpMode = transport === "http";
1748
1903
  const externalHost = process.env.JOPLIN_HOST !== void 0 && process.env.JOPLIN_HOST !== "" ? process.env.JOPLIN_HOST : void 0;
1749
1904
  const externalPort = process.env.JOPLIN_PORT !== void 0 && process.env.JOPLIN_PORT !== "" ? parseInt(process.env.JOPLIN_PORT, 10) : void 0;
1750
1905
  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");
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");
1753
1916
  process.exit(1);
1754
1917
  }
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)}`;
1918
+ const readOrCreateProfileToken = () => {
1758
1919
  const tokenPath = path.join(profileDir, ".mcp-token");
1759
1920
  try {
1760
1921
  const saved = fs.readFileSync(tokenPath, "utf-8").trim();
1761
1922
  if (saved) return saved;
1762
1923
  } catch {}
1763
- const token = `mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 32)}`;
1924
+ const token = generateToken();
1764
1925
  try {
1765
1926
  fs.mkdirSync(profileDir, { recursive: true });
1766
1927
  fs.writeFileSync(tokenPath, token);
1767
1928
  } catch {}
1768
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
+ }
1769
1940
  })();
1770
1941
  async function setupConnection() {
1771
1942
  if (externalMode) {
@@ -1782,7 +1953,8 @@ async function setupConnection() {
1782
1953
  profileDir,
1783
1954
  apiPort: DEFAULT_API_PORT,
1784
1955
  apiToken: joplinToken,
1785
- syncTarget: syncTarget.orUndefined()
1956
+ syncTarget: syncTarget.orUndefined(),
1957
+ version: "2.2.0"
1786
1958
  });
1787
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`));
1788
1960
  sidecar.start().then((result) => {
@@ -1834,7 +2006,7 @@ async function startStdioServer(host, port, token, sidecar) {
1834
2006
  });
1835
2007
  const server = new Server({
1836
2008
  name: "joplin-mcp-server",
1837
- version: "2.1.1"
2009
+ version: "2.2.0"
1838
2010
  }, { capabilities: {
1839
2011
  resources: {},
1840
2012
  tools: {},