@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.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/dist/index.js +252 -7
  3. 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. Every value asked in `setup` can be skipped with Enter; anything you skip goes into the `env` block shown below instead. `setup --print` shows these snippets again at any time.
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
- function jsonSnippet(missing) {
379
+ /** The `mcpServers.<key>` value used by JSON-configured clients. */
380
+ function serverEntry(missing) {
378
381
  const server = {
379
382
  command: "npx",
380
- args: ["-y", `${PACKAGE_NAME}@latest`]
383
+ args: SERVER_ARGS
381
384
  };
382
385
  if (Object.keys(missing).length > 0) server.env = missing;
383
- return JSON.stringify({ mcpServers: { [SERVER_KEY]: server } }, null, 2);
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)) p.note(s.body, s.title);
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
- if (Object.keys(missing).length > 0) p.log.warn(`Still needed: ${Object.keys(missing).join(", ")}. Add them to the env block below, or run setup again.`);
500
- printSnippets(missing);
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.2",
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",