@stealthmole/mcp 0.1.11 → 0.1.13
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/bin/claude-desktop-config.mjs +38 -0
- package/bin/cli.mjs +39 -20
- package/bin/node-version-guard.mjs +56 -0
- package/bin/token-cache-shim.mjs +162 -0
- package/package.json +1 -1
|
@@ -27,6 +27,29 @@ const CONFIG_FILENAME = "claude_desktop_config.json";
|
|
|
27
27
|
/** npx specifier for the retired `stealthmole.mcp` package, with or without a version range. */
|
|
28
28
|
const RETIRED_PACKAGE_ARG = /^stealthmole\.mcp(@.+)?$/;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* npx specifier for either package that launches this server.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately wider than {@link RETIRED_PACKAGE_ARG}: that one drives the
|
|
34
|
+
* rewrite and must not touch an entry already on the scoped package, while this
|
|
35
|
+
* one only answers "is this entry ours".
|
|
36
|
+
*/
|
|
37
|
+
const SERVER_PACKAGE_ARG = /^(?:@stealthmole\/mcp|stealthmole\.mcp)(@.+)?$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether an `mcpServers` entry launches this server.
|
|
41
|
+
*
|
|
42
|
+
* Matched on `args`, not `command`: the command is `npx`, `npx.cmd`, or an
|
|
43
|
+
* absolute path depending on platform and install, while the package specifier
|
|
44
|
+
* is identical everywhere.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} entry an `mcpServers` entry
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
function launchesThisServer(entry) {
|
|
50
|
+
return Array.isArray(entry?.args) && entry.args.some((arg) => SERVER_PACKAGE_ARG.test(arg));
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
/**
|
|
31
54
|
* Repoint an entry's npx args at `@stealthmole/mcp`.
|
|
32
55
|
*
|
|
@@ -166,6 +189,21 @@ export function ensureClaudeDesktopConfig({
|
|
|
166
189
|
return "migrated";
|
|
167
190
|
}
|
|
168
191
|
|
|
192
|
+
// The customer may have registered this server by hand under a key of
|
|
193
|
+
// their own choosing. Identity is the package the entry launches, not the
|
|
194
|
+
// key it sits under — judging by key alone adds a SECOND entry, so the
|
|
195
|
+
// server runs twice, every tool shows up twice, and the entry we add
|
|
196
|
+
// carries none of their `env` (notably STEALTHMOLE_CLIENT_SECRET).
|
|
197
|
+
const foreignKey = Object.keys(servers).find((key) => launchesThisServer(servers[key]));
|
|
198
|
+
if (foreignKey) {
|
|
199
|
+
if (debug) {
|
|
200
|
+
console.error(
|
|
201
|
+
`${LOG_PREFIX} Already registered under "${foreignKey}", leaving it as is.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return "unchanged";
|
|
205
|
+
}
|
|
206
|
+
|
|
169
207
|
servers[SERVER_KEY] = {
|
|
170
208
|
command: "npx",
|
|
171
209
|
args: ["-y", `@stealthmole/mcp@^${pkgVersion}`],
|
package/bin/cli.mjs
CHANGED
|
@@ -7,12 +7,46 @@ import { createServer } from "net";
|
|
|
7
7
|
import { readFileSync } from "fs";
|
|
8
8
|
|
|
9
9
|
import { ensureClaudeDesktopConfig } from "./claude-desktop-config.mjs";
|
|
10
|
+
import { unsupportedNodeMessage } from "./node-version-guard.mjs";
|
|
11
|
+
import { migrateTokenCache } from "./token-cache-shim.mjs";
|
|
10
12
|
|
|
11
13
|
// Read current package version for pinned auto-registration
|
|
12
14
|
const PKG_VERSION = JSON.parse(
|
|
13
15
|
readFileSync(new URL("../package.json", import.meta.url), "utf-8")
|
|
14
16
|
).version;
|
|
15
17
|
|
|
18
|
+
// --help and --version come first. Both are diagnostics a customer runs to
|
|
19
|
+
// report what they have installed, so nothing below may run ahead of them:
|
|
20
|
+
// a fresh registration exits the process, and argv filtering rewrites
|
|
21
|
+
// process.argv out from under them.
|
|
22
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
23
|
+
console.error(
|
|
24
|
+
`@stealthmole/mcp v${PKG_VERSION}\n` +
|
|
25
|
+
"\nUsage: npx -y @stealthmole/mcp [options]\n" +
|
|
26
|
+
"\nOptions:\n" +
|
|
27
|
+
" --port <number> Callback port for OAuth redirect\n" +
|
|
28
|
+
" --callback-port <number> Alias for --port\n" +
|
|
29
|
+
" --debug Enable debug logging\n" +
|
|
30
|
+
" --help, -h Show this help message\n" +
|
|
31
|
+
" --version, -v Show version number\n"
|
|
32
|
+
);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
37
|
+
console.error(PKG_VERSION);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Refuse an unsupported Node before touching anything. Registering a config
|
|
42
|
+
// entry that cannot run is worse than not registering: Claude Desktop would
|
|
43
|
+
// keep launching a server that crashes inside node_modules.
|
|
44
|
+
const nodeProblem = unsupportedNodeMessage();
|
|
45
|
+
if (nodeProblem) {
|
|
46
|
+
console.error(nodeProblem);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
16
50
|
// Register (or migrate) the Claude Desktop entry before starting the proxy.
|
|
17
51
|
// A rename keeps running: Claude Desktop launched this process under the old
|
|
18
52
|
// key, so exiting would break the session the user is in right now.
|
|
@@ -36,26 +70,6 @@ const AUTH_SUCCESS_URL = "https://www.stealthmole.com/";
|
|
|
36
70
|
const CALLBACK_HOST = "localhost";
|
|
37
71
|
const DEBUG = process.argv.includes("--debug");
|
|
38
72
|
|
|
39
|
-
// Handle --help and --version before argv filtering
|
|
40
|
-
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
41
|
-
console.error(
|
|
42
|
-
`@stealthmole/mcp v${PKG_VERSION}\n` +
|
|
43
|
-
"\nUsage: npx -y @stealthmole/mcp [options]\n" +
|
|
44
|
-
"\nOptions:\n" +
|
|
45
|
-
" --port <number> Callback port for OAuth redirect\n" +
|
|
46
|
-
" --callback-port <number> Alias for --port\n" +
|
|
47
|
-
" --debug Enable debug logging\n" +
|
|
48
|
-
" --help, -h Show this help message\n" +
|
|
49
|
-
" --version, -v Show version number\n"
|
|
50
|
-
);
|
|
51
|
-
process.exit(0);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
55
|
-
console.error(PKG_VERSION);
|
|
56
|
-
process.exit(0);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
73
|
/**
|
|
60
74
|
* Find an available port not occupied by another process.
|
|
61
75
|
*
|
|
@@ -223,6 +237,11 @@ process.argv.push("--host", CALLBACK_HOST);
|
|
|
223
237
|
process.argv.push("--static-oauth-client-info", CLIENT_INFO);
|
|
224
238
|
process.argv.push("--static-oauth-client-metadata", CLIENT_METADATA);
|
|
225
239
|
|
|
240
|
+
// Carry a login saved by an older mcp-remote into the store the current one
|
|
241
|
+
// reads. Must run before the proxy starts, otherwise it asks for a browser
|
|
242
|
+
// sign-in the customer has already completed once.
|
|
243
|
+
migrateTokenCache({ serverUrl: SERVER_URL, debug: DEBUG });
|
|
244
|
+
|
|
226
245
|
// Suppress mcp-remote startup logs unless --debug is set.
|
|
227
246
|
// Uses mcp-remote's built-in --silent flag (safer than overriding console.error).
|
|
228
247
|
if (!DEBUG) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Node.js version guard for the StealthMole MCP client.
|
|
2
|
+
//
|
|
3
|
+
// mcp-remote depends on undici, which dereferences the global `File` at require
|
|
4
|
+
// time (undici/lib/web/webidl/index.js). `File` became global in Node 20, so on
|
|
5
|
+
// Node 18 the proxy dies with `ReferenceError: File is not defined` and a stack
|
|
6
|
+
// trace through node_modules — the customer sees a crash, not a cause.
|
|
7
|
+
//
|
|
8
|
+
// `engines.node` does not prevent this: npm and npx print a warning and run
|
|
9
|
+
// anyway. The check has to happen at runtime.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
|
|
13
|
+
const LOG_PREFIX = "[stealthmole-mcp]";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Node.js version declared in `engines.node`, for the install advice.
|
|
17
|
+
*
|
|
18
|
+
* Read from package.json so the number is written once. It mirrors undici's own
|
|
19
|
+
* `engines` and is only a recommendation here — the guard below refuses strictly
|
|
20
|
+
* less, because versions between 20.0.0 and this one still run fine (measured:
|
|
21
|
+
* 20.9.0 loads undici without error).
|
|
22
|
+
*/
|
|
23
|
+
export const RECOMMENDED_NODE_VERSION = JSON.parse(
|
|
24
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf-8")
|
|
25
|
+
).engines.node.replace(/^[^\d]*/, "");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Explain why this Node cannot run the client, or `null` when it can.
|
|
29
|
+
*
|
|
30
|
+
* Tests the missing global directly instead of comparing version numbers.
|
|
31
|
+
* A version range has to be guessed and then maintained as undici moves, and
|
|
32
|
+
* guessing it too high is the worse error: it would refuse customers who are
|
|
33
|
+
* working today. The global either exists or it does not.
|
|
34
|
+
*
|
|
35
|
+
* Returns the message rather than printing and exiting, so the decision to
|
|
36
|
+
* terminate stays with the caller and the rule stays unit-testable.
|
|
37
|
+
*
|
|
38
|
+
* @param {boolean} [hasFileGlobal] whether `File` is a global in this runtime
|
|
39
|
+
* @param {string} [current] version to name in the message
|
|
40
|
+
* @returns {string|null}
|
|
41
|
+
*/
|
|
42
|
+
export function unsupportedNodeMessage(
|
|
43
|
+
hasFileGlobal = typeof File !== "undefined",
|
|
44
|
+
current = process.versions.node
|
|
45
|
+
) {
|
|
46
|
+
if (hasFileGlobal) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
`${LOG_PREFIX} Node.js ${current} is too old: mcp-remote needs the global ` +
|
|
52
|
+
`"File", which Node.js added in version 20.\n` +
|
|
53
|
+
`${LOG_PREFIX} Install Node.js ${RECOMMENDED_NODE_VERSION} or newer from ` +
|
|
54
|
+
`https://nodejs.org/ and restart Claude Desktop.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -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