@stealthmole/mcp 0.1.10 → 0.1.12

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,26 @@
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";
10
+ import { migrateTokenCache } from "./token-cache-shim.mjs";
16
11
 
17
12
  // Read current package version for pinned auto-registration
18
13
  const PKG_VERSION = JSON.parse(
19
14
  readFileSync(new URL("../package.json", import.meta.url), "utf-8")
20
15
  ).version;
21
16
 
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();
17
+ // Register (or migrate) the Claude Desktop entry before starting the proxy.
18
+ // A rename keeps running: Claude Desktop launched this process under the old
19
+ // key, so exiting would break the session the user is in right now.
20
+ const configStatus = ensureClaudeDesktopConfig({
21
+ pkgVersion: PKG_VERSION,
22
+ debug: process.argv.includes("--debug"),
23
+ });
121
24
 
122
- // First run after registration: exit without starting the proxy
123
- if (newlyRegistered) {
25
+ // First run after a fresh registration: exit without starting the proxy
26
+ if (configStatus === "registered") {
124
27
  process.exit(0);
125
28
  }
126
29
 
@@ -321,6 +224,11 @@ process.argv.push("--host", CALLBACK_HOST);
321
224
  process.argv.push("--static-oauth-client-info", CLIENT_INFO);
322
225
  process.argv.push("--static-oauth-client-metadata", CLIENT_METADATA);
323
226
 
227
+ // Carry a login saved by an older mcp-remote into the store the current one
228
+ // reads. Must run before the proxy starts, otherwise it asks for a browser
229
+ // sign-in the customer has already completed once.
230
+ migrateTokenCache({ serverUrl: SERVER_URL, debug: DEBUG });
231
+
324
232
  // Suppress mcp-remote startup logs unless --debug is set.
325
233
  // Uses mcp-remote's built-in --silent flag (safer than overriding console.error).
326
234
  if (!DEBUG) {
@@ -0,0 +1,162 @@
1
+ // Carry a saved OAuth login over to mcp-remote's renamed token store.
2
+ //
3
+ // mcp-remote 0.15 moved its token store from `~/.mcp-auth/mcp-remote-<version>/`
4
+ // to `~/.mcp-auth/mcp-remote-v<CONFIG_STORE_VERSION>/`. Tokens written by the
5
+ // version we shipped before (0.1.38) are therefore in a directory the current
6
+ // one never looks at, which pushes every upgrading customer into a fresh browser
7
+ // login. Customers who do not complete that login get no error — the proxy waits
8
+ // for authorization indefinitely and Claude Desktop simply shows no tools.
9
+ //
10
+ // The store filename is `md5(serverUrl)_tokens.json` in both versions and the
11
+ // added fields (`issuer`, `expires_at`, `requested_scope`) are optional, so
12
+ // copying the old file across is enough: mcp-remote gets a 401 on the stale
13
+ // access token and silently refreshes it with the Keycloak offline token.
14
+ //
15
+ // TEMPORARY. This couples to mcp-remote's private store layout. Delete it once
16
+ // either is true:
17
+ // - mcp-remote bumps CONFIG_STORE_VERSION past 1, making the target dead, or
18
+ // - no customers remain on a version older than 0.1.12.
19
+ //
20
+ // NOTE: In MCP (Model Context Protocol), stdout is reserved for JSON-RPC messages.
21
+ // All user-facing messages MUST use console.error (stderr), not console.log (stdout).
22
+
23
+ import { readdirSync, statSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
24
+ import { join, dirname } from "path";
25
+ import { homedir } from "os";
26
+ import { createHash } from "crypto";
27
+
28
+ const LOG_PREFIX = "[stealthmole-mcp]";
29
+
30
+ /**
31
+ * Store directory mcp-remote 0.15 reads.
32
+ *
33
+ * Mirrors `mcp-remote-v${CONFIG_STORE_VERSION}` in its `lib/mcp-auth-config.ts`.
34
+ */
35
+ export const CONFIG_STORE_DIR = "mcp-remote-v1";
36
+
37
+ /**
38
+ * Store directories named after an mcp-remote release, e.g. `mcp-remote-0.1.37`.
39
+ *
40
+ * Deliberately anchored on a digit: a `mcp-remote-v2` directory is a *newer*
41
+ * layout, and copying out of it would move tokens into a store that has been
42
+ * retired.
43
+ */
44
+ const LEGACY_STORE_DIR = /^mcp-remote-\d/;
45
+
46
+ /**
47
+ * Resolve the base directory holding mcp-remote's token stores.
48
+ *
49
+ * @param {Record<string, string|undefined>} [env] environment variables
50
+ * @param {string} [home] home directory; injectable so tests can stay hermetic
51
+ * @returns {string} absolute path
52
+ */
53
+ export function resolveBaseDir(env = process.env, home = homedir()) {
54
+ return env.MCP_REMOTE_CONFIG_DIR || join(home, ".mcp-auth");
55
+ }
56
+
57
+ /**
58
+ * Find the legacy store holding the most recently written copy of `tokensFile`.
59
+ *
60
+ * Ordering is by the token file's own mtime rather than the directory's: the
61
+ * directory is touched by lock and verifier files from flows that never
62
+ * completed, so only the token file identifies where the customer last
63
+ * successfully signed in.
64
+ *
65
+ * @param {string} baseDir
66
+ * @param {string} tokensFile
67
+ * @returns {string|null} absolute path to the newest match
68
+ */
69
+ function findLegacyTokens(baseDir, tokensFile) {
70
+ let entries;
71
+ try {
72
+ entries = readdirSync(baseDir, { withFileTypes: true });
73
+ } catch (err) {
74
+ if (err.code === "ENOENT") {
75
+ // mcp-remote has never run on this machine — nothing to carry over
76
+ return null;
77
+ }
78
+ throw err;
79
+ }
80
+
81
+ let newest = null;
82
+ let newestMtime = -Infinity;
83
+
84
+ for (const entry of entries) {
85
+ if (!entry.isDirectory() || !LEGACY_STORE_DIR.test(entry.name)) {
86
+ continue;
87
+ }
88
+
89
+ const candidate = join(baseDir, entry.name, tokensFile);
90
+ let mtimeMs;
91
+ try {
92
+ mtimeMs = statSync(candidate).mtimeMs;
93
+ } catch {
94
+ // No token file for this server in that store
95
+ continue;
96
+ }
97
+
98
+ if (mtimeMs > newestMtime) {
99
+ newest = candidate;
100
+ newestMtime = mtimeMs;
101
+ }
102
+ }
103
+
104
+ return newest;
105
+ }
106
+
107
+ /**
108
+ * Copy a saved login into the token store the current mcp-remote reads.
109
+ *
110
+ * Non-destructive in every direction: an existing token file is never
111
+ * overwritten, the legacy copy is never deleted, and a hash mismatch or an
112
+ * unreadable store leaves the normal browser sign-in as the fallback.
113
+ *
114
+ * Outcomes:
115
+ * - `"copied"` — a legacy login was carried over
116
+ * - `"present"` — the current store already has a token file
117
+ * - `"none"` — no legacy login found for this server
118
+ * - `"failed"` — the store could not be read or written; non-fatal
119
+ *
120
+ * @param {object} options
121
+ * @param {string} options.serverUrl MCP server URL the tokens belong to
122
+ * @param {string} [options.baseDir] override the resolved store base directory
123
+ * @param {boolean} [options.debug] include the error message on failure
124
+ * @returns {"copied"|"present"|"none"|"failed"}
125
+ */
126
+ export function migrateTokenCache({ serverUrl, baseDir = resolveBaseDir(), debug = false }) {
127
+ try {
128
+ const serverUrlHash = createHash("md5").update(serverUrl).digest("hex");
129
+ const tokensFile = `${serverUrlHash}_tokens.json`;
130
+ const target = join(baseDir, CONFIG_STORE_DIR, tokensFile);
131
+
132
+ if (existsSync(target)) {
133
+ return "present";
134
+ }
135
+
136
+ const source = findLegacyTokens(baseDir, tokensFile);
137
+ if (!source) {
138
+ return "none";
139
+ }
140
+
141
+ // mcp-remote creates this itself, but only after the point where it would
142
+ // already have asked for a browser login — upgrades straight from 0.1.8
143
+ // reach here with no store at all.
144
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
145
+
146
+ // Read-then-write rather than copyFile: the source mode is whatever the
147
+ // older version left behind, and a refresh token must stay owner-only.
148
+ writeFileSync(target, readFileSync(source), { mode: 0o600 });
149
+
150
+ // Logged unconditionally, not just under --debug: support needs to be able
151
+ // to tell "the shim ran" apart from "the shim found nothing".
152
+ console.error(`${LOG_PREFIX} ✔ Restored your saved sign-in from a previous version.`);
153
+ return "copied";
154
+ } catch (err) {
155
+ // Non-fatal: the customer signs in through the browser as before
156
+ if (debug) {
157
+ console.error(`${LOG_PREFIX} ⚠ Could not carry over the saved sign-in: ${err.message}`);
158
+ console.error(err.stack);
159
+ }
160
+ return "failed";
161
+ }
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stealthmole/mcp",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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"