mcpman 0.6.0 → 0.8.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/README.md CHANGED
@@ -283,6 +283,162 @@ mcpman import backup.json --yes # skip confirmation
283
283
  mcpman import backup.json --dry-run # preview without applying
284
284
  ```
285
285
 
286
+ ### `create [name]`
287
+
288
+ Scaffold a new MCP server project with working boilerplate.
289
+
290
+ ```sh
291
+ mcpman create my-server # interactive prompts
292
+ mcpman create my-server --yes # accept defaults
293
+ mcpman create my-server --runtime python # Python template
294
+ ```
295
+
296
+ Generates `package.json` (with `mcp` field), `src/index.ts`, and `tsconfig.json` for Node; or `pyproject.toml` and `main.py` for Python. Both templates implement the MCP protocol with a sample `hello` tool ready to run.
297
+
298
+ ### `link [dir]`
299
+
300
+ Register a local MCP server directory with AI clients — like `npm link` but for MCP.
301
+
302
+ ```sh
303
+ mcpman link . # link current directory
304
+ mcpman link ./path/to/server # link specific directory
305
+ mcpman link . --client cursor # link to specific client only
306
+ mcpman link . --name my-override # override detected server name
307
+ ```
308
+
309
+ Reads `package.json` or `pyproject.toml` to detect name, version, and entry point. Adds a lockfile entry with `source: "local"` and registers the absolute path in client configs. No file copying — edits are picked up immediately.
310
+
311
+ ### `watch <server>`
312
+
313
+ Watch a local MCP server's source files and auto-restart on changes — like nodemon, built into mcpman.
314
+
315
+ ```sh
316
+ mcpman watch my-server # watch with defaults
317
+ mcpman watch my-server --dir ./src # override watch directory
318
+ mcpman watch my-server --ext ts,js # watch specific extensions
319
+ mcpman watch my-server --delay 500 # set debounce delay (ms)
320
+ mcpman watch my-server --clear # clear terminal on restart
321
+ ```
322
+
323
+ Uses Node.js built-in `fs.watch` (no chokidar). Debounces 300ms by default. Ignores `node_modules/`, `dist/`, `.git/`, `__pycache__/`. Vault secrets are injected same as `mcpman run`.
324
+
325
+ ### `registry <list|add|remove|set-default>`
326
+
327
+ Manage custom registry URLs for MCP server resolution.
328
+
329
+ ```sh
330
+ mcpman registry list # show all registries
331
+ mcpman registry add corp https://mcp.corp.com/api # add custom registry
332
+ mcpman registry remove corp # remove custom registry
333
+ mcpman registry set-default smithery # change default registry
334
+ ```
335
+
336
+ Built-in registries (npm, smithery) are always present and cannot be removed. Custom registries are stored in `~/.mcpman/config.json`.
337
+
338
+ ### `completions <bash|zsh|fish|install>`
339
+
340
+ Generate shell completion scripts for tab-completion of commands and server names.
341
+
342
+ ```sh
343
+ mcpman completions bash # output bash completion script
344
+ mcpman completions zsh # output zsh completion script
345
+ mcpman completions fish # output fish completion script
346
+ mcpman completions install # auto-detect shell and install
347
+ source <(mcpman completions bash) # enable completions in current session
348
+ ```
349
+
350
+ Completes: subcommands, server names (from lockfile), client types (`--client`), and runtimes (`--runtime`). Server names are resolved dynamically at completion time so they stay fresh.
351
+
352
+ ### `why <server>`
353
+
354
+ Show why a server is installed — source, clients, profiles, env vars.
355
+
356
+ ```sh
357
+ mcpman why my-server # full provenance output
358
+ mcpman why my-server --json # JSON output for scripting
359
+ ```
360
+
361
+ Displays: source (npm/smithery/github/local), resolved URL, version, installed timestamp, which clients have it registered, which named profiles include it, and required env var names. Detects orphaned servers (in client config but not in lockfile) and suggests `mcpman sync --remove`.
362
+
363
+ ### `env <set|get|list|del|clear>`
364
+
365
+ Manage per-server environment variables (non-sensitive defaults).
366
+
367
+ ```sh
368
+ mcpman env set my-server API_URL=https://api.example.com
369
+ mcpman env get my-server API_URL
370
+ mcpman env list my-server
371
+ mcpman env del my-server API_URL
372
+ mcpman env clear my-server
373
+ ```
374
+
375
+ Stored in `~/.mcpman/env/<server>.json`. For sensitive values, use `mcpman secrets` instead. At runtime, vault secrets take priority over env defaults.
376
+
377
+ ### `bench <server>`
378
+
379
+ Benchmark MCP server latency with JSON-RPC initialize calls.
380
+
381
+ ```sh
382
+ mcpman bench my-server # 5 runs (default)
383
+ mcpman bench my-server --runs 10 # custom run count
384
+ mcpman bench my-server --json # machine-readable output
385
+ mcpman bench my-server --timeout 5000 # exit 1 if p95 > 5s
386
+ ```
387
+
388
+ Reports min, max, avg, p50, p95 response times in milliseconds.
389
+
390
+ ### `diff <client-a> <client-b>`
391
+
392
+ Show visual diff of MCP server configs between two AI clients.
393
+
394
+ ```sh
395
+ mcpman diff claude-desktop cursor # color-coded diff
396
+ mcpman diff vscode windsurf --json # JSON output
397
+ ```
398
+
399
+ Displays added (green), removed (red), and changed (yellow) servers between two client configurations. Useful before running `mcpman sync`.
400
+
401
+ ### `group <add|rm|list|delete|install|run>`
402
+
403
+ Organize servers into named groups for batch operations.
404
+
405
+ ```sh
406
+ mcpman group add work server-a server-b # tag servers
407
+ mcpman group rm work server-b # untag server
408
+ mcpman group list # show all groups
409
+ mcpman group list work # show group members
410
+ mcpman group install work # install all in group
411
+ mcpman group run work # run all concurrently
412
+ mcpman group delete work # remove entire group
413
+ ```
414
+
415
+ Groups are lightweight labels stored in `~/.mcpman/groups.json`. Unlike profiles (full snapshots), groups are just server name lists for convenience.
416
+
417
+ ### `pin <server> [version]`
418
+
419
+ Pin a server to a specific version to prevent auto-updates.
420
+
421
+ ```sh
422
+ mcpman pin my-server 1.2.3 # pin to exact version
423
+ mcpman pin my-server # pin to current version
424
+ mcpman pin --unpin my-server # remove pin
425
+ mcpman pin --list # show all pinned servers
426
+ ```
427
+
428
+ Pinned servers are skipped by `mcpman update` and version check notifications. Pins stored in `~/.mcpman/pins.json`.
429
+
430
+ ### `rollback [index]`
431
+
432
+ Restore a previous lockfile state from automatic snapshots.
433
+
434
+ ```sh
435
+ mcpman rollback --list # show snapshot history
436
+ mcpman rollback 0 # restore most recent snapshot
437
+ mcpman rollback 2 # restore specific snapshot
438
+ ```
439
+
440
+ Snapshots are created automatically before every lockfile write. Keeps the last 5 snapshots in `~/.mcpman/rollback/`. After rollback, run `mcpman sync` to apply to all clients.
441
+
286
442
  ---
287
443
 
288
444
  ## Comparison
@@ -306,6 +462,18 @@ mcpman import backup.json --dry-run # preview without applying
306
462
  | Self-upgrade | Built-in CLI updater | None | None |
307
463
  | Interactive setup | Yes | Partial | No |
308
464
  | Project-scoped | Yes (`init`) | No | No |
465
+ | Server scaffolding | `create` (Node + Python) | None | None |
466
+ | Local dev linking | `link` (like npm link) | None | None |
467
+ | File watching | `watch` (auto-restart) | None | None |
468
+ | Custom registries | `registry` CRUD | None | None |
469
+ | Shell completions | bash + zsh + fish | None | None |
470
+ | Provenance query | `why` (clients + profiles) | None | None |
471
+ | Env management | Per-server env var CRUD | None | None |
472
+ | Benchmarking | Latency p50/p95 stats | None | None |
473
+ | Config diff | Visual client diff | None | None |
474
+ | Server groups | Batch install/run tags | None | None |
475
+ | Version pinning | `pin`/`unpin` CLI | None | None |
476
+ | Rollback | Auto-snapshot + restore | None | None |
309
477
 
310
478
  ---
311
479
 
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/utils/paths.ts
4
+ import os from "os";
5
+ import path from "path";
6
+ function getHomedir() {
7
+ return os.homedir();
8
+ }
9
+ function getMcpmanDir() {
10
+ return path.join(os.homedir(), ".mcpman");
11
+ }
12
+ function getConfigPath() {
13
+ return path.join(getMcpmanDir(), "config.json");
14
+ }
15
+ function getPluginDir() {
16
+ return path.join(getMcpmanDir(), "plugins");
17
+ }
18
+ function getProfilesDir() {
19
+ return path.join(getMcpmanDir(), "profiles");
20
+ }
21
+ function getEnvDir() {
22
+ return path.join(getMcpmanDir(), "env");
23
+ }
24
+ function getGroupsFile() {
25
+ return path.join(getMcpmanDir(), "groups.json");
26
+ }
27
+ function getPinsFile() {
28
+ return path.join(getMcpmanDir(), "pins.json");
29
+ }
30
+ function getRollbackDir() {
31
+ return path.join(getMcpmanDir(), "rollback");
32
+ }
33
+ function getAppDataDir() {
34
+ const home = getHomedir();
35
+ if (process.platform === "darwin") {
36
+ return path.join(home, "Library", "Application Support");
37
+ }
38
+ if (process.platform === "win32") {
39
+ return process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
40
+ }
41
+ return process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
42
+ }
43
+ function resolveConfigPath(client) {
44
+ const appData = getAppDataDir();
45
+ const home = getHomedir();
46
+ switch (client) {
47
+ case "claude-desktop":
48
+ return path.join(appData, "Claude", "claude_desktop_config.json");
49
+ case "cursor":
50
+ return path.join(appData, "Cursor", "User", "globalStorage", "cursor.mcp", "mcp.json");
51
+ case "windsurf":
52
+ return path.join(
53
+ appData,
54
+ "Windsurf",
55
+ "User",
56
+ "globalStorage",
57
+ "windsurf.mcpConfigJson",
58
+ "mcp.json"
59
+ );
60
+ case "vscode":
61
+ if (process.platform === "darwin") {
62
+ return path.join(appData, "Code", "User", "settings.json");
63
+ }
64
+ if (process.platform === "win32") {
65
+ return path.join(appData, "Code", "User", "settings.json");
66
+ }
67
+ return path.join(home, ".config", "Code", "User", "settings.json");
68
+ }
69
+ }
70
+
71
+ export {
72
+ getConfigPath,
73
+ getPluginDir,
74
+ getProfilesDir,
75
+ getEnvDir,
76
+ getGroupsFile,
77
+ getPinsFile,
78
+ getRollbackDir,
79
+ resolveConfigPath
80
+ };
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ getRollbackDir
4
+ } from "./chunk-AOCGFOA6.js";
5
+
6
+ // src/core/lockfile.ts
7
+ import fs2 from "fs";
8
+ import os from "os";
9
+ import path2 from "path";
10
+
11
+ // src/core/rollback-service.ts
12
+ import fs from "fs";
13
+ import path from "path";
14
+ var MAX_SNAPSHOTS = 5;
15
+ function ensureDir(dir) {
16
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
17
+ }
18
+ function listSnapshotFiles(dir) {
19
+ if (!fs.existsSync(dir)) return [];
20
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
21
+ }
22
+ function snapshotBeforeWrite(content, rollbackDir) {
23
+ const dir = rollbackDir ?? getRollbackDir();
24
+ ensureDir(dir);
25
+ const existing = listSnapshotFiles(dir);
26
+ if (existing.length > 0) {
27
+ const latest = existing[existing.length - 1];
28
+ try {
29
+ const prev = fs.readFileSync(path.join(dir, latest), "utf-8");
30
+ if (prev === content) return;
31
+ } catch {
32
+ }
33
+ }
34
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
35
+ const filename = `${ts}.json`;
36
+ fs.writeFileSync(path.join(dir, filename), content, "utf-8");
37
+ evictOldSnapshots(dir);
38
+ }
39
+ function evictOldSnapshots(rollbackDir) {
40
+ const dir = rollbackDir ?? getRollbackDir();
41
+ const files = listSnapshotFiles(dir);
42
+ const excess = files.length - MAX_SNAPSHOTS;
43
+ for (let i = 0; i < excess; i++) {
44
+ try {
45
+ fs.unlinkSync(path.join(dir, files[i]));
46
+ } catch {
47
+ }
48
+ }
49
+ }
50
+ function listSnapshots(rollbackDir) {
51
+ const dir = rollbackDir ?? getRollbackDir();
52
+ const files = listSnapshotFiles(dir).reverse();
53
+ return files.map((filename, index) => {
54
+ const filepath = path.join(dir, filename);
55
+ let sizeBytes = 0;
56
+ let createdAt = "";
57
+ try {
58
+ const stat = fs.statSync(filepath);
59
+ sizeBytes = stat.size;
60
+ createdAt = stat.mtime.toISOString();
61
+ } catch {
62
+ }
63
+ return { index, filename, createdAt, sizeBytes };
64
+ });
65
+ }
66
+ function readSnapshot(index, rollbackDir) {
67
+ const dir = rollbackDir ?? getRollbackDir();
68
+ const files = listSnapshotFiles(dir).reverse();
69
+ const filename = files[index];
70
+ if (!filename) return null;
71
+ try {
72
+ return fs.readFileSync(path.join(dir, filename), "utf-8");
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ function restoreSnapshot(index, targetPath, rollbackDir) {
78
+ const content = readSnapshot(index, rollbackDir);
79
+ if (content === null) return null;
80
+ const dir = path.dirname(targetPath);
81
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
82
+ const tmp = `${targetPath}.tmp`;
83
+ fs.writeFileSync(tmp, content, "utf-8");
84
+ fs.renameSync(tmp, targetPath);
85
+ return content;
86
+ }
87
+
88
+ // src/core/lockfile.ts
89
+ var LOCKFILE_NAME = "mcpman.lock";
90
+ function findLockfile() {
91
+ let dir = process.cwd();
92
+ while (true) {
93
+ const candidate = path2.join(dir, LOCKFILE_NAME);
94
+ if (fs2.existsSync(candidate)) return candidate;
95
+ const parent = path2.dirname(dir);
96
+ if (parent === dir) break;
97
+ dir = parent;
98
+ }
99
+ return null;
100
+ }
101
+ function getGlobalLockfilePath() {
102
+ return path2.join(os.homedir(), ".mcpman", LOCKFILE_NAME);
103
+ }
104
+ function resolveLockfilePath() {
105
+ return findLockfile() ?? getGlobalLockfilePath();
106
+ }
107
+ function readLockfile(filePath) {
108
+ const target = filePath ?? resolveLockfilePath();
109
+ if (!fs2.existsSync(target)) {
110
+ return { lockfileVersion: 1, servers: {} };
111
+ }
112
+ try {
113
+ const raw = fs2.readFileSync(target, "utf-8");
114
+ return JSON.parse(raw);
115
+ } catch {
116
+ return { lockfileVersion: 1, servers: {} };
117
+ }
118
+ }
119
+ function serialize(data) {
120
+ const sorted = {
121
+ lockfileVersion: data.lockfileVersion,
122
+ servers: Object.fromEntries(
123
+ Object.entries(data.servers).sort(([a], [b]) => a.localeCompare(b))
124
+ )
125
+ };
126
+ return `${JSON.stringify(sorted, null, 2)}
127
+ `;
128
+ }
129
+ function writeLockfile(data, filePath) {
130
+ const target = filePath ?? resolveLockfilePath();
131
+ const dir = path2.dirname(target);
132
+ if (!fs2.existsSync(dir)) {
133
+ fs2.mkdirSync(dir, { recursive: true });
134
+ }
135
+ const serialized = serialize(data);
136
+ if (fs2.existsSync(target)) {
137
+ try {
138
+ const current = fs2.readFileSync(target, "utf-8");
139
+ snapshotBeforeWrite(current);
140
+ } catch {
141
+ }
142
+ }
143
+ const tmp = `${target}.tmp`;
144
+ fs2.writeFileSync(tmp, serialized, "utf-8");
145
+ fs2.renameSync(tmp, target);
146
+ }
147
+ function addEntry(name, entry, filePath) {
148
+ const data = readLockfile(filePath);
149
+ data.servers[name] = entry;
150
+ writeLockfile(data, filePath);
151
+ }
152
+ function removeEntry(name, filePath) {
153
+ const data = readLockfile(filePath);
154
+ if (name in data.servers) {
155
+ delete data.servers[name];
156
+ writeLockfile(data, filePath);
157
+ }
158
+ }
159
+ function getLockedVersion(name, filePath) {
160
+ const data = readLockfile(filePath);
161
+ return data.servers[name]?.version;
162
+ }
163
+ function createEmptyLockfile(filePath) {
164
+ writeLockfile({ lockfileVersion: 1, servers: {} }, filePath);
165
+ }
166
+
167
+ export {
168
+ listSnapshots,
169
+ readSnapshot,
170
+ restoreSnapshot,
171
+ LOCKFILE_NAME,
172
+ findLockfile,
173
+ getGlobalLockfilePath,
174
+ resolveLockfilePath,
175
+ readLockfile,
176
+ writeLockfile,
177
+ addEntry,
178
+ removeEntry,
179
+ getLockedVersion,
180
+ createEmptyLockfile
181
+ };
@@ -1,64 +1,11 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/utils/paths.ts
4
- import os from "os";
5
- import path from "path";
6
- function getHomedir() {
7
- return os.homedir();
8
- }
9
- function getMcpmanDir() {
10
- return path.join(os.homedir(), ".mcpman");
11
- }
12
- function getConfigPath() {
13
- return path.join(getMcpmanDir(), "config.json");
14
- }
15
- function getPluginDir() {
16
- return path.join(getMcpmanDir(), "plugins");
17
- }
18
- function getProfilesDir() {
19
- return path.join(getMcpmanDir(), "profiles");
20
- }
21
- function getAppDataDir() {
22
- const home = getHomedir();
23
- if (process.platform === "darwin") {
24
- return path.join(home, "Library", "Application Support");
25
- }
26
- if (process.platform === "win32") {
27
- return process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
28
- }
29
- return process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
30
- }
31
- function resolveConfigPath(client) {
32
- const appData = getAppDataDir();
33
- const home = getHomedir();
34
- switch (client) {
35
- case "claude-desktop":
36
- return path.join(appData, "Claude", "claude_desktop_config.json");
37
- case "cursor":
38
- return path.join(appData, "Cursor", "User", "globalStorage", "cursor.mcp", "mcp.json");
39
- case "windsurf":
40
- return path.join(
41
- appData,
42
- "Windsurf",
43
- "User",
44
- "globalStorage",
45
- "windsurf.mcpConfigJson",
46
- "mcp.json"
47
- );
48
- case "vscode":
49
- if (process.platform === "darwin") {
50
- return path.join(appData, "Code", "User", "settings.json");
51
- }
52
- if (process.platform === "win32") {
53
- return path.join(appData, "Code", "User", "settings.json");
54
- }
55
- return path.join(home, ".config", "Code", "User", "settings.json");
56
- }
57
- }
2
+ import {
3
+ resolveConfigPath
4
+ } from "./chunk-AOCGFOA6.js";
58
5
 
59
6
  // src/clients/base-client-handler.ts
60
7
  import fs from "fs";
61
- import path2 from "path";
8
+ import path from "path";
62
9
 
63
10
  // src/clients/types.ts
64
11
  var ConfigParseError = class extends Error {
@@ -80,7 +27,7 @@ var ConfigWriteError = class extends Error {
80
27
  async function atomicWrite(filePath, content) {
81
28
  const tmpPath = `${filePath}.tmp`;
82
29
  try {
83
- await fs.promises.mkdir(path2.dirname(filePath), { recursive: true });
30
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
84
31
  await fs.promises.writeFile(tmpPath, content, { encoding: "utf-8", mode: 384 });
85
32
  await fs.promises.rename(tmpPath, filePath);
86
33
  } catch (err) {
@@ -101,7 +48,7 @@ async function pathExists(p) {
101
48
  }
102
49
  var BaseClientHandler = class {
103
50
  async isInstalled() {
104
- const dir = path2.dirname(this.getConfigPath());
51
+ const dir = path.dirname(this.getConfigPath());
105
52
  return pathExists(dir);
106
53
  }
107
54
  /** Read raw JSON from disk, return empty object if file missing */
@@ -228,9 +175,6 @@ async function getInstalledClients() {
228
175
  }
229
176
 
230
177
  export {
231
- getConfigPath,
232
- getPluginDir,
233
- getProfilesDir,
234
178
  getAllClientTypes,
235
179
  getClient,
236
180
  getInstalledClients
@@ -3,7 +3,8 @@ import {
3
3
  getAllClientTypes,
4
4
  getClient,
5
5
  getInstalledClients
6
- } from "./chunk-NS6HV723.js";
6
+ } from "./chunk-XPYCEHZZ.js";
7
+ import "./chunk-AOCGFOA6.js";
7
8
  export {
8
9
  getAllClientTypes,
9
10
  getClient,