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 +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 +283 -42
- 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.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.
|
|
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
|
|
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,25 +203,51 @@ 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
|
|
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
|
|
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("~/"))
|
|
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")
|
|
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
|
|
492
|
-
syncPath
|
|
493
|
-
syncUsername
|
|
494
|
-
syncPassword
|
|
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
|
|
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 `
|
|
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
|
-
|
|
1752
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
2078
|
+
version: "2.2.1"
|
|
1838
2079
|
}, { capabilities: {
|
|
1839
2080
|
resources: {},
|
|
1840
2081
|
tools: {},
|