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 +1 -1
- package/dist/bin.js +3 -3
- package/dist/bin.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +213 -41
- package/dist/index.js.map +1 -1
- package/package.json +11 -17
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
89
|
-
if (
|
|
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
|
-
|
|
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
|
|
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("~/"))
|
|
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")
|
|
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
|
|
492
|
-
syncPath
|
|
493
|
-
syncUsername
|
|
494
|
-
syncPassword
|
|
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
|
|
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
|
-
|
|
1752
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
2009
|
+
version: "2.2.0"
|
|
1838
2010
|
}, { capabilities: {
|
|
1839
2011
|
resources: {},
|
|
1840
2012
|
tools: {},
|