@fswap/mcp-outline 0.1.2 → 0.1.5
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/README.md +3 -1
- package/dist/index.js +252 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,7 +15,9 @@ Runs locally over stdio. No install step — clients launch it with `npx`.
|
|
|
15
15
|
|
|
16
16
|
It asks for your Outline URL and token, verifies them against `auth.info`, and stores them in your OS config directory (mode `0600`).
|
|
17
17
|
|
|
18
|
-
3. Add the server to your client.
|
|
18
|
+
3. Add the server to your client. At the end, `setup` offers to do this for you in Claude Code, Claude Desktop, Cursor and Codex: clients it finds are pre-selected (except Claude Code when Claude Desktop is installed, because the desktop app's Code tab already loads `claude_desktop_config.json`), an existing `outline` entry is only replaced after you confirm, and any config file it changes gets a one-time `.bak` copy.
|
|
19
|
+
|
|
20
|
+
To add it by hand instead, use the snippets below. Every value asked in `setup` can be skipped with Enter; anything you skip goes into the `env` block instead. `setup --print` shows these snippets again at any time.
|
|
19
21
|
|
|
20
22
|
**Claude Desktop** (`claude_desktop_config.json`) and **Cursor** (`~/.cursor/mcp.json` or `<project>/.cursor/mcp.json`):
|
|
21
23
|
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,8 @@ import path from "node:path";
|
|
|
7
7
|
import envPaths from "env-paths";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import * as p from "@clack/prompts";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
10
12
|
//#region \0rolldown/runtime.js
|
|
11
13
|
var __defProp = Object.defineProperty;
|
|
12
14
|
var __esmMin = (fn, res, err) => () => {
|
|
@@ -374,13 +376,17 @@ function registerDocumentTools(server, outline, { allowDelete = false } = {}) {
|
|
|
374
376
|
}
|
|
375
377
|
//#endregion
|
|
376
378
|
//#region src/snippets.ts
|
|
377
|
-
|
|
379
|
+
/** The `mcpServers.<key>` value used by JSON-configured clients. */
|
|
380
|
+
function serverEntry(missing) {
|
|
378
381
|
const server = {
|
|
379
382
|
command: "npx",
|
|
380
|
-
args:
|
|
383
|
+
args: SERVER_ARGS
|
|
381
384
|
};
|
|
382
385
|
if (Object.keys(missing).length > 0) server.env = missing;
|
|
383
|
-
return
|
|
386
|
+
return server;
|
|
387
|
+
}
|
|
388
|
+
function jsonSnippet(missing) {
|
|
389
|
+
return JSON.stringify({ mcpServers: { [SERVER_KEY]: serverEntry(missing) } }, null, 2);
|
|
384
390
|
}
|
|
385
391
|
function tomlSnippet(missing) {
|
|
386
392
|
const lines = [
|
|
@@ -418,11 +424,199 @@ function clientSnippets(missing = {}) {
|
|
|
418
424
|
}
|
|
419
425
|
];
|
|
420
426
|
}
|
|
421
|
-
var SERVER_KEY, ENV_URL, ENV_TOKEN;
|
|
427
|
+
var SERVER_KEY, ENV_URL, ENV_TOKEN, SERVER_ARGS;
|
|
422
428
|
var init_snippets = __esmMin((() => {
|
|
423
429
|
SERVER_KEY = "outline";
|
|
424
430
|
ENV_URL = "OUTLINE_URL";
|
|
425
431
|
ENV_TOKEN = "OUTLINE_API_TOKEN";
|
|
432
|
+
SERVER_ARGS = ["-y", `${PACKAGE_NAME}@latest`];
|
|
433
|
+
}));
|
|
434
|
+
//#endregion
|
|
435
|
+
//#region src/install.ts
|
|
436
|
+
function onPath(cmd) {
|
|
437
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
438
|
+
return (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).some((dir) => exts.some((ext) => fs.existsSync(path.join(dir, cmd + ext))));
|
|
439
|
+
}
|
|
440
|
+
function runCommand(cmd, args) {
|
|
441
|
+
const r = spawnSync(cmd, args, {
|
|
442
|
+
encoding: "utf8",
|
|
443
|
+
shell: process.platform === "win32"
|
|
444
|
+
});
|
|
445
|
+
return {
|
|
446
|
+
ok: r.status === 0,
|
|
447
|
+
output: `${r.stdout ?? ""}${r.stderr ?? ""}${r.error?.message ?? ""}`.trim()
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function claudeDesktopConfig(env) {
|
|
451
|
+
if (env.platform === "darwin") return path.join(env.home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
452
|
+
if (env.platform === "win32") return path.join(env.appData ?? path.join(env.home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
453
|
+
return path.join(env.home, ".config", "Claude", "claude_desktop_config.json");
|
|
454
|
+
}
|
|
455
|
+
function detectClients(env = hostEnv) {
|
|
456
|
+
const dirExists = (file) => fs.existsSync(path.dirname(file));
|
|
457
|
+
return [
|
|
458
|
+
{
|
|
459
|
+
id: "claude-code",
|
|
460
|
+
label: "Claude Code",
|
|
461
|
+
detected: env.which("claude"),
|
|
462
|
+
location: CLAUDE_CODE_LOCATION,
|
|
463
|
+
hint: "terminal / IDE"
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
id: "claude-desktop",
|
|
467
|
+
label: "Claude Desktop",
|
|
468
|
+
detected: dirExists(claudeDesktopConfig(env)),
|
|
469
|
+
location: claudeDesktopConfig(env),
|
|
470
|
+
hint: "chat + Code tab"
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
id: "cursor",
|
|
474
|
+
label: "Cursor",
|
|
475
|
+
detected: dirExists(cursorConfig(env)),
|
|
476
|
+
location: cursorConfig(env)
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
id: "codex",
|
|
480
|
+
label: "Codex",
|
|
481
|
+
detected: dirExists(codexConfig(env)) || env.which("codex"),
|
|
482
|
+
location: codexConfig(env)
|
|
483
|
+
}
|
|
484
|
+
];
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Detected clients to pre-select. Claude Code is left out when Claude Desktop is present: the desktop
|
|
488
|
+
* app's Code tab also loads claude_desktop_config.json, so selecting both would register the server twice there.
|
|
489
|
+
*/
|
|
490
|
+
function defaultSelection(clients) {
|
|
491
|
+
const desktopFound = clients.some((c) => c.id === "claude-desktop" && c.detected);
|
|
492
|
+
return clients.filter((c) => c.detected && !(desktopFound && c.id === "claude-code")).map((c) => c.id);
|
|
493
|
+
}
|
|
494
|
+
function readIfExists(file) {
|
|
495
|
+
try {
|
|
496
|
+
return fs.readFileSync(file, "utf8");
|
|
497
|
+
} catch (err) {
|
|
498
|
+
if (err.code === "ENOENT") return null;
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Writes `content`, keeping a copy of the original the first time an existing file is changed. */
|
|
503
|
+
function writeWithBackup(file, original, content) {
|
|
504
|
+
if (original !== null && !fs.existsSync(`${file}.bak`)) fs.writeFileSync(`${file}.bak`, original);
|
|
505
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
506
|
+
fs.writeFileSync(file, content);
|
|
507
|
+
}
|
|
508
|
+
function parseJsonConfig(file, text) {
|
|
509
|
+
if (text === null || text.trim() === "") return {};
|
|
510
|
+
try {
|
|
511
|
+
const parsed = JSON.parse(text);
|
|
512
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
513
|
+
} catch {}
|
|
514
|
+
throw new Error(`Could not parse ${file}; left it unchanged`);
|
|
515
|
+
}
|
|
516
|
+
function jsonHasEntry(file) {
|
|
517
|
+
return Boolean(parseJsonConfig(file, readIfExists(file)).mcpServers?.[SERVER_KEY]);
|
|
518
|
+
}
|
|
519
|
+
function jsonInstall(file, missing) {
|
|
520
|
+
const original = readIfExists(file);
|
|
521
|
+
const config = parseJsonConfig(file, original);
|
|
522
|
+
const replaced = Boolean(config.mcpServers?.[SERVER_KEY]);
|
|
523
|
+
config.mcpServers = {
|
|
524
|
+
...config.mcpServers,
|
|
525
|
+
[SERVER_KEY]: serverEntry(missing)
|
|
526
|
+
};
|
|
527
|
+
writeWithBackup(file, original, JSON.stringify(config, null, 2) + "\n");
|
|
528
|
+
return {
|
|
529
|
+
status: replaced ? "replaced" : "added",
|
|
530
|
+
location: file
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function tomlHasEntry(text) {
|
|
534
|
+
return text !== null && text.split("\n").some((line) => ownHeader.test(line));
|
|
535
|
+
}
|
|
536
|
+
function tomlWithoutEntry(text) {
|
|
537
|
+
let inOwnSection = false;
|
|
538
|
+
return text.split("\n").filter((line) => {
|
|
539
|
+
if (anyHeader.test(line)) inOwnSection = ownHeader.test(line);
|
|
540
|
+
return !inOwnSection;
|
|
541
|
+
}).join("\n");
|
|
542
|
+
}
|
|
543
|
+
function codexInstall(file, missing) {
|
|
544
|
+
const original = readIfExists(file);
|
|
545
|
+
const rest = original === null ? "" : tomlWithoutEntry(original).trimEnd();
|
|
546
|
+
writeWithBackup(file, original, (rest ? `${rest}\n\n` : "") + tomlSnippet(missing) + "\n");
|
|
547
|
+
return {
|
|
548
|
+
status: tomlHasEntry(original) ? "replaced" : "added",
|
|
549
|
+
location: file
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function claude(env, args) {
|
|
553
|
+
const r = env.run("claude", args);
|
|
554
|
+
if (!r.ok) throw new Error(`claude ${args.join(" ")} failed${r.output ? `: ${r.output}` : ""}`);
|
|
555
|
+
}
|
|
556
|
+
function claudeCodeInstall(env, missing) {
|
|
557
|
+
const replaced = hasEntry("claude-code", env);
|
|
558
|
+
if (replaced) claude(env, [
|
|
559
|
+
"mcp",
|
|
560
|
+
"remove",
|
|
561
|
+
SERVER_KEY,
|
|
562
|
+
"-s",
|
|
563
|
+
"user"
|
|
564
|
+
]);
|
|
565
|
+
const envArgs = Object.entries(missing).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
|
|
566
|
+
claude(env, [
|
|
567
|
+
"mcp",
|
|
568
|
+
"add",
|
|
569
|
+
SERVER_KEY,
|
|
570
|
+
"-s",
|
|
571
|
+
"user",
|
|
572
|
+
...envArgs,
|
|
573
|
+
"--",
|
|
574
|
+
"npx",
|
|
575
|
+
...SERVER_ARGS
|
|
576
|
+
]);
|
|
577
|
+
return {
|
|
578
|
+
status: replaced ? "replaced" : "added",
|
|
579
|
+
location: CLAUDE_CODE_LOCATION
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/** Whether the client already has a server registered under SERVER_KEY. Throws if its config is unreadable. */
|
|
583
|
+
function hasEntry(id, env = hostEnv) {
|
|
584
|
+
switch (id) {
|
|
585
|
+
case "claude-code": return env.run("claude", [
|
|
586
|
+
"mcp",
|
|
587
|
+
"get",
|
|
588
|
+
SERVER_KEY
|
|
589
|
+
]).ok;
|
|
590
|
+
case "claude-desktop": return jsonHasEntry(claudeDesktopConfig(env));
|
|
591
|
+
case "cursor": return jsonHasEntry(cursorConfig(env));
|
|
592
|
+
case "codex": return tomlHasEntry(readIfExists(codexConfig(env)));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/** Adds (or overwrites) this server in the client's config. Throws with a readable message on failure. */
|
|
596
|
+
function installClient(id, missing, env = hostEnv) {
|
|
597
|
+
switch (id) {
|
|
598
|
+
case "claude-code": return claudeCodeInstall(env, missing);
|
|
599
|
+
case "claude-desktop": return jsonInstall(claudeDesktopConfig(env), missing);
|
|
600
|
+
case "cursor": return jsonInstall(cursorConfig(env), missing);
|
|
601
|
+
case "codex": return codexInstall(codexConfig(env), missing);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
var hostEnv, cursorConfig, codexConfig, CLAUDE_CODE_LOCATION, escapeRe, ownHeader, anyHeader;
|
|
605
|
+
var init_install = __esmMin((() => {
|
|
606
|
+
init_snippets();
|
|
607
|
+
hostEnv = {
|
|
608
|
+
home: os.homedir(),
|
|
609
|
+
platform: process.platform,
|
|
610
|
+
appData: process.env.APPDATA,
|
|
611
|
+
which: onPath,
|
|
612
|
+
run: runCommand
|
|
613
|
+
};
|
|
614
|
+
cursorConfig = (env) => path.join(env.home, ".cursor", "mcp.json");
|
|
615
|
+
codexConfig = (env) => path.join(env.home, ".codex", "config.toml");
|
|
616
|
+
CLAUDE_CODE_LOCATION = "user scope via claude mcp";
|
|
617
|
+
escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
618
|
+
ownHeader = new RegExp(`^\\s*\\[\\s*mcp_servers\\.("?)${escapeRe(SERVER_KEY)}\\1\\s*(\\]|\\.)`);
|
|
619
|
+
anyHeader = /^\s*\[/;
|
|
426
620
|
}));
|
|
427
621
|
//#endregion
|
|
428
622
|
//#region src/setup.ts
|
|
@@ -431,8 +625,13 @@ function abort() {
|
|
|
431
625
|
p.cancel("Setup aborted.");
|
|
432
626
|
process.exit(1);
|
|
433
627
|
}
|
|
628
|
+
/** Plain stdout, no box drawing, so the snippets can be selected and copied (or piped to a file). */
|
|
434
629
|
function printSnippets(missing) {
|
|
435
|
-
for (const s of clientSnippets(missing))
|
|
630
|
+
for (const s of clientSnippets(missing)) {
|
|
631
|
+
console.log(`\n# ${s.title}\n`);
|
|
632
|
+
console.log(s.body);
|
|
633
|
+
}
|
|
634
|
+
console.log();
|
|
436
635
|
}
|
|
437
636
|
async function runSetup(args = []) {
|
|
438
637
|
if (args.includes("--reset")) {
|
|
@@ -496,13 +695,59 @@ async function runSetup(args = []) {
|
|
|
496
695
|
const missing = {};
|
|
497
696
|
if (!url) missing[ENV_URL] = "https://app.getoutline.com";
|
|
498
697
|
if (!token) missing[ENV_TOKEN] = "ol_api_...";
|
|
499
|
-
|
|
500
|
-
|
|
698
|
+
const updated = await addToClients(missing);
|
|
699
|
+
if (Object.keys(missing).length > 0) {
|
|
700
|
+
const where = updated ? "env block of the client config updated above" : "env block below";
|
|
701
|
+
p.log.warn(`Still needed: ${Object.keys(missing).join(", ")}. Replace the placeholders in the ${where}, or run setup again.`);
|
|
702
|
+
}
|
|
501
703
|
p.outro(`Saved to ${file}`);
|
|
704
|
+
if (updated) {
|
|
705
|
+
console.log("Re-print MCP client config snippets any time with: setup --print");
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
console.log("Add one of these to your MCP client (plain text, safe to copy). Re-print any time with: setup --print");
|
|
709
|
+
printSnippets(missing);
|
|
710
|
+
}
|
|
711
|
+
/** Offers to register the server in MCP clients. Returns true when at least one client was updated. */
|
|
712
|
+
async function addToClients(missing) {
|
|
713
|
+
const clients = detectClients();
|
|
714
|
+
const selected = await p.multiselect({
|
|
715
|
+
message: "Add to MCP clients? (Space to toggle, Enter to confirm, none to skip)",
|
|
716
|
+
options: clients.map((c) => ({
|
|
717
|
+
value: c.id,
|
|
718
|
+
label: c.label,
|
|
719
|
+
hint: [c.hint, c.detected ? "detected" : "not found"].filter(Boolean).join(" — ")
|
|
720
|
+
})),
|
|
721
|
+
initialValues: defaultSelection(clients),
|
|
722
|
+
required: false
|
|
723
|
+
});
|
|
724
|
+
if (typeof selected === "symbol") abort();
|
|
725
|
+
let updated = false;
|
|
726
|
+
for (const client of clients.filter((c) => selected.includes(c.id))) try {
|
|
727
|
+
if (hasEntry(client.id)) {
|
|
728
|
+
const replace = await p.confirm({
|
|
729
|
+
message: `${client.label} already has a "${SERVER_KEY}" server. Replace it?`,
|
|
730
|
+
initialValue: false
|
|
731
|
+
});
|
|
732
|
+
if (typeof replace === "symbol") abort();
|
|
733
|
+
if (!replace) {
|
|
734
|
+
p.log.info(`${client.label}: skipped, existing entry kept`);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const result = installClient(client.id, missing);
|
|
739
|
+
const restart = client.id === "claude-desktop" ? " — restart Claude Desktop to load it" : "";
|
|
740
|
+
p.log.success(`${client.label}: ${result.status} (${result.location})${restart}`);
|
|
741
|
+
updated = true;
|
|
742
|
+
} catch (err) {
|
|
743
|
+
p.log.error(`${client.label}: failed — ${err.message}`);
|
|
744
|
+
}
|
|
745
|
+
return updated;
|
|
502
746
|
}
|
|
503
747
|
var init_setup = __esmMin((() => {
|
|
504
748
|
init_config();
|
|
505
749
|
init_snippets();
|
|
750
|
+
init_install();
|
|
506
751
|
}));
|
|
507
752
|
//#endregion
|
|
508
753
|
//#region src/index.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fswap/mcp-outline",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "MCP server for Outline (getoutline.com) — search, read, create and update wiki documents from Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"dev": "tsdown --watch",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
19
|
"lint": "eslint src test",
|
|
20
|
-
"test": "npm run build && node test/smoke.test.mjs",
|
|
20
|
+
"test": "npm run build && node test/smoke.test.mjs && node --test test/install.test.mjs",
|
|
21
21
|
"check": "npm run lint && npm run typecheck && npm test",
|
|
22
22
|
"start": "node dist/index.js",
|
|
23
23
|
"setup": "node dist/index.js setup",
|