@stealthmole/mcp 0.1.10 → 0.1.11

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.
@@ -0,0 +1,201 @@
1
+ // Claude Desktop config registration for the StealthMole MCP server.
2
+ //
3
+ // NOTE: In MCP (Model Context Protocol), stdout is reserved for JSON-RPC messages.
4
+ // All user-facing messages MUST use console.error (stderr), not console.log (stdout).
5
+
6
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "fs";
7
+ import { join, dirname } from "path";
8
+ import { platform, homedir } from "os";
9
+
10
+ const LOG_PREFIX = "[stealthmole-mcp]";
11
+
12
+ /** Key this server is registered under in `mcpServers`. */
13
+ export const SERVER_KEY = "stealthmole-mcp";
14
+
15
+ /**
16
+ * Keys this server used to be registered under.
17
+ *
18
+ * `stealthmole.mcp` mirrored the npm package of the same name, which has since
19
+ * been deprecated in favour of `@stealthmole/mcp`. A legacy entry is renamed
20
+ * rather than left in place, otherwise the user ends up running the same server
21
+ * twice and sees every tool duplicated.
22
+ */
23
+ export const LEGACY_SERVER_KEYS = ["stealthmole.mcp"];
24
+
25
+ const CONFIG_FILENAME = "claude_desktop_config.json";
26
+
27
+ /** npx specifier for the retired `stealthmole.mcp` package, with or without a version range. */
28
+ const RETIRED_PACKAGE_ARG = /^stealthmole\.mcp(@.+)?$/;
29
+
30
+ /**
31
+ * Repoint an entry's npx args at `@stealthmole/mcp`.
32
+ *
33
+ * A hand-written entry may still launch the retired `stealthmole.mcp` wrapper,
34
+ * which pins the user to whatever `@stealthmole/mcp` version that wrapper
35
+ * depends on. Only the package specifier is swapped — the user's own flags and
36
+ * `env` are left alone.
37
+ *
38
+ * @param {object} entry an `mcpServers` entry
39
+ * @param {string} pkgVersion version to pin
40
+ * @returns {object} the entry, with args repointed when needed
41
+ */
42
+ function repointRetiredPackage(entry, pkgVersion) {
43
+ if (!Array.isArray(entry?.args)) {
44
+ return entry;
45
+ }
46
+
47
+ const args = entry.args.map((arg) =>
48
+ RETIRED_PACKAGE_ARG.test(arg) ? `@stealthmole/mcp@^${pkgVersion}` : arg
49
+ );
50
+
51
+ return args.some((arg, i) => arg !== entry.args[i]) ? { ...entry, args } : entry;
52
+ }
53
+
54
+ /**
55
+ * Resolve Claude Desktop's config file path for the current platform.
56
+ *
57
+ * @param {string} [os] platform identifier, as returned by `os.platform()`
58
+ * @param {Record<string, string|undefined>} [env] environment variables
59
+ * @param {string} [home] home directory; injectable so tests can stay hermetic
60
+ * @returns {string|null} absolute path, or null when it cannot be determined
61
+ */
62
+ export function resolveConfigPath(os = platform(), env = process.env, home = homedir()) {
63
+ let configDir;
64
+
65
+ if (os === "darwin") {
66
+ configDir = join(home, "Library", "Application Support", "Claude");
67
+ } else if (os === "win32") {
68
+ if (!env.APPDATA) {
69
+ return null;
70
+ }
71
+ configDir = join(env.APPDATA, "Claude");
72
+ } else {
73
+ // Linux: use XDG_CONFIG_HOME or fall back to ~/.config
74
+ configDir = join(env.XDG_CONFIG_HOME || join(home, ".config"), "Claude");
75
+ }
76
+
77
+ return join(configDir, CONFIG_FILENAME);
78
+ }
79
+
80
+ /**
81
+ * Write `config` to `configPath` atomically.
82
+ *
83
+ * Writes to a sibling temp file then renames, so a concurrent run can never
84
+ * observe (or be truncated to) a half-written config.
85
+ *
86
+ * @param {string} configPath
87
+ * @param {object} config
88
+ */
89
+ function writeConfigAtomically(configPath, config) {
90
+ // Owner-only permissions: the config may hold a client secret
91
+ mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
92
+
93
+ const tmpPath = configPath + ".tmp";
94
+ try {
95
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2) + "\n", {
96
+ encoding: "utf-8",
97
+ mode: 0o600,
98
+ });
99
+ renameSync(tmpPath, configPath);
100
+ } catch (writeErr) {
101
+ try {
102
+ unlinkSync(tmpPath);
103
+ } catch {
104
+ /* ignore cleanup failure */
105
+ }
106
+ throw writeErr;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Ensure this server is registered in Claude Desktop under {@link SERVER_KEY}.
112
+ *
113
+ * Outcomes:
114
+ * - `"unchanged"` — already registered correctly, config untouched
115
+ * - `"migrated"` — a legacy key was renamed; the caller should keep running so
116
+ * the current session (launched under the old key) still works
117
+ * - `"registered"` — newly added; the caller should exit and ask for a restart
118
+ * - `"failed"` — config could not be read or written; non-fatal
119
+ *
120
+ * @param {object} options
121
+ * @param {string} options.pkgVersion version to pin in the npx args
122
+ * @param {boolean} [options.debug] include the error message on failure
123
+ * @param {string|null} [options.configPath] override the resolved config path
124
+ * @returns {"unchanged"|"migrated"|"registered"|"failed"}
125
+ */
126
+ export function ensureClaudeDesktopConfig({
127
+ pkgVersion,
128
+ debug = false,
129
+ configPath = resolveConfigPath(),
130
+ }) {
131
+ if (!configPath) {
132
+ console.error(`${LOG_PREFIX} Could not determine the Claude Desktop config path, skipping registration.`);
133
+ return "failed";
134
+ }
135
+
136
+ try {
137
+ let config;
138
+ try {
139
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
140
+ } catch {
141
+ // File missing or invalid JSON — start fresh
142
+ config = {};
143
+ }
144
+
145
+ if (!config.mcpServers) {
146
+ config.mcpServers = {};
147
+ }
148
+
149
+ const servers = config.mcpServers;
150
+ const staleKeys = LEGACY_SERVER_KEYS.filter((key) => key in servers);
151
+
152
+ if (staleKeys.length === 0) {
153
+ const current = servers[SERVER_KEY];
154
+ if (current) {
155
+ // The key may be current while the args still launch the retired wrapper
156
+ const repointed = repointRetiredPackage(current, pkgVersion);
157
+ if (repointed === current) {
158
+ return "unchanged";
159
+ }
160
+
161
+ servers[SERVER_KEY] = repointed;
162
+ writeConfigAtomically(configPath, config);
163
+
164
+ console.error(`\n${LOG_PREFIX} ✔ Repointed "${SERVER_KEY}" at @stealthmole/mcp in Claude Desktop settings.`);
165
+ console.error(`${LOG_PREFIX} Restart Claude Desktop when convenient; this session keeps working.\n`);
166
+ return "migrated";
167
+ }
168
+
169
+ servers[SERVER_KEY] = {
170
+ command: "npx",
171
+ args: ["-y", `@stealthmole/mcp@^${pkgVersion}`],
172
+ };
173
+ writeConfigAtomically(configPath, config);
174
+
175
+ console.error(`\n${LOG_PREFIX} ✔ Registered ${SERVER_KEY} in Claude Desktop settings.`);
176
+ console.error(`${LOG_PREFIX} Please restart Claude Desktop to apply the changes.\n`);
177
+ return "registered";
178
+ }
179
+
180
+ // Carry the legacy entry over so user customisation (env, extra args)
181
+ // survives the rename. An entry already under SERVER_KEY wins.
182
+ for (const key of staleKeys) {
183
+ servers[SERVER_KEY] ??= repointRetiredPackage(servers[key], pkgVersion);
184
+ delete servers[key];
185
+ }
186
+ writeConfigAtomically(configPath, config);
187
+
188
+ console.error(`\n${LOG_PREFIX} ✔ Renamed "${staleKeys.join('", "')}" to "${SERVER_KEY}" in Claude Desktop settings.`);
189
+ console.error(`${LOG_PREFIX} Restart Claude Desktop when convenient; this session keeps working.\n`);
190
+ return "migrated";
191
+ } catch (err) {
192
+ // Config write failed — warn and let the caller continue normal execution
193
+ if (debug) {
194
+ console.error(`${LOG_PREFIX} ⚠ Failed to update Claude Desktop settings: ${err.message}`);
195
+ console.error(err.stack);
196
+ } else {
197
+ console.error(`${LOG_PREFIX} ⚠ Failed to update Claude Desktop settings.`);
198
+ }
199
+ return "failed";
200
+ }
201
+ }
package/bin/cli.mjs CHANGED
@@ -4,123 +4,25 @@
4
4
  // All user-facing messages MUST use console.error (stderr), not console.log (stdout).
5
5
 
6
6
  import { createServer } from "net";
7
- import {
8
- readFileSync,
9
- writeFileSync,
10
- mkdirSync,
11
- renameSync,
12
- unlinkSync,
13
- } from "fs";
14
- import { join } from "path";
15
- import { platform, homedir } from "os";
7
+ import { readFileSync } from "fs";
8
+
9
+ import { ensureClaudeDesktopConfig } from "./claude-desktop-config.mjs";
16
10
 
17
11
  // Read current package version for pinned auto-registration
18
12
  const PKG_VERSION = JSON.parse(
19
13
  readFileSync(new URL("../package.json", import.meta.url), "utf-8")
20
14
  ).version;
21
15
 
22
- /**
23
- * Auto-register the StealthMole MCP server in Claude Desktop's claude_desktop_config.json
24
- * if not already present. Returns true if newly registered, false otherwise.
25
- */
26
- function ensureClaudeDesktopConfig() {
27
- try {
28
- const os = platform();
29
- let configDir;
30
-
31
- if (os === "darwin") {
32
- configDir = join(
33
- homedir(),
34
- "Library",
35
- "Application Support",
36
- "Claude"
37
- );
38
- } else if (os === "win32") {
39
- if (!process.env.APPDATA) {
40
- console.error(
41
- "[stealthmole-mcp] APPDATA environment variable not set, skipping config registration."
42
- );
43
- return false;
44
- }
45
- configDir = join(process.env.APPDATA, "Claude");
46
- } else {
47
- // Linux: use XDG_CONFIG_HOME or fall back to ~/.config
48
- configDir = join(
49
- process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
50
- "Claude"
51
- );
52
- }
53
-
54
- const configPath = join(configDir, "claude_desktop_config.json");
55
-
56
- let config;
57
- try {
58
- config = JSON.parse(readFileSync(configPath, "utf-8"));
59
- } catch {
60
- // File missing or invalid JSON — start fresh
61
- config = {};
62
- }
63
-
64
- if (!config.mcpServers) {
65
- config.mcpServers = {};
66
- }
67
-
68
- // Already registered — nothing to do
69
- if (config.mcpServers["stealthmole.mcp"]) {
70
- return false;
71
- }
72
-
73
- config.mcpServers["stealthmole.mcp"] = {
74
- command: "npx",
75
- args: ["-y", `@stealthmole/mcp@^${PKG_VERSION}`],
76
- };
77
-
78
- // Create directory with owner-only permissions if it doesn't exist
79
- mkdirSync(configDir, { recursive: true, mode: 0o700 });
80
-
81
- // Atomic write: write to temp file then rename to prevent data loss on concurrent runs
82
- const tmpPath = configPath + ".tmp";
83
- try {
84
- writeFileSync(tmpPath, JSON.stringify(config, null, 2) + "\n", {
85
- encoding: "utf-8",
86
- mode: 0o600,
87
- });
88
- renameSync(tmpPath, configPath);
89
- } catch (writeErr) {
90
- try {
91
- unlinkSync(tmpPath);
92
- } catch {
93
- /* ignore cleanup failure */
94
- }
95
- throw writeErr;
96
- }
97
-
98
- console.error(
99
- "\n[stealthmole-mcp] ✔ Registered stealthmole.mcp in Claude Desktop settings."
100
- );
101
- console.error(
102
- "[stealthmole-mcp] Please restart Claude Desktop to apply the changes.\n"
103
- );
104
- return true;
105
- } catch (err) {
106
- // Config write failed — warn and continue normal execution
107
- if (process.argv.includes("--debug")) {
108
- console.error(
109
- `[stealthmole-mcp] ⚠ Failed to auto-register in Claude Desktop settings: ${err.message}`
110
- );
111
- } else {
112
- console.error(
113
- "[stealthmole-mcp] ⚠ Failed to auto-register in Claude Desktop settings."
114
- );
115
- }
116
- return false;
117
- }
118
- }
119
-
120
- const newlyRegistered = ensureClaudeDesktopConfig();
16
+ // Register (or migrate) the Claude Desktop entry before starting the proxy.
17
+ // A rename keeps running: Claude Desktop launched this process under the old
18
+ // key, so exiting would break the session the user is in right now.
19
+ const configStatus = ensureClaudeDesktopConfig({
20
+ pkgVersion: PKG_VERSION,
21
+ debug: process.argv.includes("--debug"),
22
+ });
121
23
 
122
- // First run after registration: exit without starting the proxy
123
- if (newlyRegistered) {
24
+ // First run after a fresh registration: exit without starting the proxy
25
+ if (configStatus === "registered") {
124
26
  process.exit(0);
125
27
  }
126
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stealthmole/mcp",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "StealthMole Threat Intelligence MCP client - connect Claude Desktop, Cursor, and other MCP clients to StealthMole API",
5
5
  "bin": {
6
6
  "stealthmole-mcp": "bin/cli.mjs"