@stealthmole/mcp 0.1.7 → 0.1.8

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/cli.mjs CHANGED
@@ -1,8 +1,327 @@
1
1
  #!/usr/bin/env node
2
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 { 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";
16
+
17
+ // Read current package version for pinned auto-registration
18
+ const PKG_VERSION = JSON.parse(
19
+ readFileSync(new URL("../package.json", import.meta.url), "utf-8")
20
+ ).version;
21
+
22
+ /**
23
+ * Auto-register stealthmole.mcp 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();
121
+
122
+ // First run after registration: exit without starting the proxy
123
+ if (newlyRegistered) {
124
+ process.exit(0);
125
+ }
126
+
127
+ const SERVER_URL = "https://mcp.stealthmole.com/mcp";
128
+ const DEBUG = process.argv.includes("--debug");
129
+
130
+ // Handle --help and --version before argv filtering
131
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
132
+ console.error(
133
+ `stealthmole.mcp v${PKG_VERSION}\n` +
134
+ "\nUsage: npx stealthmole.mcp [options]\n" +
135
+ "\nOptions:\n" +
136
+ " --port <number> Callback port for OAuth redirect\n" +
137
+ " --callback-port <number> Alias for --port\n" +
138
+ " --debug Enable debug logging\n" +
139
+ " --help, -h Show this help message\n" +
140
+ " --version, -v Show version number\n"
141
+ );
142
+ process.exit(0);
143
+ }
144
+
145
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
146
+ console.error(PKG_VERSION);
147
+ process.exit(0);
148
+ }
149
+
3
150
  /**
4
- * Thin wrapper that delegates to the main stealthmole.mcp package.
5
- * This allows `npx stealthmole-mcp` to work as an alias for `npx stealthmole.mcp`.
151
+ * Find an available port not occupied by another process.
152
+ *
153
+ * Step 1: Bind to port 0 — OS assigns a candidate port
154
+ * Step 2: Close candidate, rebind explicitly to verify availability
155
+ * (SO_REUSEADDR can cause listen(0) to return an in-use port)
156
+ * Step 3: On verification failure, retry with a new candidate (up to maxAttempts)
157
+ *
158
+ * The verified guard server is returned open to hold the port.
6
159
  */
160
+ function findFreePort(maxAttempts = 20) {
161
+ let attempts = 0;
162
+
163
+ function tryOnce() {
164
+ return new Promise((resolve, reject) => {
165
+ // Step 1: Get a candidate port from the OS
166
+ const probe = createServer();
167
+ probe.listen(0, "127.0.0.1", () => {
168
+ const { port } = probe.address();
169
+ probe.close(() => {
170
+ // Step 2: Verify by explicitly rebinding to the same port
171
+ const guard = createServer();
172
+ guard.once("error", (err) => {
173
+ // Port occupied by another process — retry
174
+ attempts++;
175
+ if (DEBUG) {
176
+ console.error(
177
+ `Port ${port} is occupied by another process, retrying... (${attempts}/${maxAttempts})`
178
+ );
179
+ }
180
+ if (attempts >= maxAttempts) {
181
+ reject(
182
+ new Error(
183
+ `Could not find a free port after ${maxAttempts} attempts`
184
+ )
185
+ );
186
+ } else {
187
+ resolve(tryOnce());
188
+ }
189
+ });
190
+ guard.listen(port, "127.0.0.1", () => {
191
+ // Port confirmed free — return with guard server still open
192
+ resolve({ port, server: guard });
193
+ });
194
+ });
195
+ });
196
+ probe.on("error", (err) => {
197
+ attempts++;
198
+ if (attempts >= maxAttempts) {
199
+ reject(err);
200
+ } else {
201
+ resolve(tryOnce());
202
+ }
203
+ });
204
+ });
205
+ }
206
+
207
+ return tryOnce();
208
+ }
209
+
210
+ // Show user-friendly message for EADDRINUSE instead of a stack trace
211
+ process.on("uncaughtException", (err) => {
212
+ if (err.code === "EADDRINUSE") {
213
+ console.error(
214
+ `[stealthmole.mcp] Port ${err.port ?? ""} is already in use. ` +
215
+ "Retrying should resolve this. " +
216
+ "To use a specific port: npx stealthmole.mcp --port <number>"
217
+ );
218
+ process.exit(1);
219
+ }
220
+ // Re-throwing inside uncaughtException causes undefined behavior in Node.js.
221
+ // Log and exit instead.
222
+ console.error("[stealthmole.mcp] Unexpected error:", err);
223
+ process.exit(1);
224
+ });
225
+
226
+ const clientInfo = {
227
+ client_id: "stealthmole_front",
228
+ redirect_uris: ["http://127.0.0.1"],
229
+ };
230
+ if (process.env.STEALTHMOLE_CLIENT_SECRET) {
231
+ clientInfo.client_secret = process.env.STEALTHMOLE_CLIENT_SECRET;
232
+ }
233
+ const CLIENT_INFO = JSON.stringify(clientInfo);
234
+
235
+ // Inline Keycloak OIDC metadata — includes offline_access for token refresh support
236
+ // scope (string) controls actual scope sent to Keycloak via mcp-remote's getEffectiveScope()
237
+ // scopes_supported (array) is declarative only — not used by mcp-remote for scope resolution
238
+ const CLIENT_METADATA = JSON.stringify({
239
+ issuer: "https://xoxo.stealthmole.com/auth/realms/stealthmole",
240
+ authorization_endpoint:
241
+ "https://xoxo.stealthmole.com/auth/realms/stealthmole/protocol/openid-connect/auth",
242
+ token_endpoint:
243
+ "https://xoxo.stealthmole.com/auth/realms/stealthmole/protocol/openid-connect/token",
244
+ registration_endpoint:
245
+ "https://xoxo.stealthmole.com/auth/realms/stealthmole/clients-registrations/openid-connect",
246
+ jwks_uri:
247
+ "https://xoxo.stealthmole.com/auth/realms/stealthmole/protocol/openid-connect/certs",
248
+ scopes_supported: ["openid", "offline_access"],
249
+ response_types_supported: ["code"],
250
+ code_challenge_methods_supported: ["S256"],
251
+ token_endpoint_auth_methods_supported: [
252
+ "client_secret_basic",
253
+ "client_secret_post",
254
+ "none",
255
+ ],
256
+ scope: "openid offline_access",
257
+ });
258
+
259
+ // Extract only allowed flags and discard the rest
260
+ // Prevents injection of unverified flags like --allow-http or --header
261
+ const ALLOWED_FLAGS = new Set(["--port", "--callback-port", "--debug"]);
262
+ const VALUE_FLAGS = new Set(["--port", "--callback-port"]);
263
+ const filteredArgs = [];
264
+ {
265
+ const rawArgs = process.argv.slice(2);
266
+ for (let i = 0; i < rawArgs.length; i++) {
267
+ if (rawArgs[i].startsWith("--")) {
268
+ if (ALLOWED_FLAGS.has(rawArgs[i])) {
269
+ if (VALUE_FLAGS.has(rawArgs[i]) && i + 1 < rawArgs.length) {
270
+ const val = rawArgs[i + 1];
271
+ const port = Number(val);
272
+ // Reject flag-like values and invalid port ranges
273
+ if (val.startsWith("--") || !Number.isInteger(port) || port < 1 || port > 65535) {
274
+ i++;
275
+ continue;
276
+ }
277
+ filteredArgs.push(rawArgs[i], val);
278
+ i++;
279
+ } else {
280
+ filteredArgs.push(rawArgs[i]);
281
+ }
282
+ }
283
+ // Unknown flags are silently ignored
284
+ }
285
+ }
286
+ }
287
+ process.argv.length = 2;
288
+ process.argv.push(...filteredArgs);
289
+
290
+ // Check if the user specified a callback port explicitly
291
+ const userSpecifiedPort =
292
+ process.argv.includes("--port") || process.argv.includes("--callback-port");
293
+
294
+ // Inject server URL as the first positional argument for mcp-remote
295
+ process.argv.splice(2, 0, SERVER_URL);
296
+
297
+ // If no port was specified, auto-discover a free port and inject it.
298
+ // The guard server holds the port until mcp-remote is ready to bind.
299
+ let guardServer = null;
300
+ if (!userSpecifiedPort) {
301
+ const { port: callbackPort, server } = await findFreePort();
302
+ guardServer = server;
303
+ process.argv.splice(3, 0, String(callbackPort));
304
+ if (DEBUG) {
305
+ console.error(
306
+ `Using automatically selected callback port: ${callbackPort}`
307
+ );
308
+ }
309
+ }
310
+
311
+ process.argv.push("--static-oauth-client-info", CLIENT_INFO);
312
+ process.argv.push("--static-oauth-client-metadata", CLIENT_METADATA);
313
+
314
+ // Suppress mcp-remote startup logs unless --debug is set.
315
+ // Uses mcp-remote's built-in --silent flag (safer than overriding console.error).
316
+ if (!DEBUG) {
317
+ process.argv.push("--silent");
318
+ }
319
+
320
+ // Release the guard server and immediately start mcp-remote to minimize the TOCTOU window.
321
+ // NOTE: mcp-remote binds the port itself, so the guard must be released first.
322
+ // This brief TOCTOU window is an architectural constraint of mcp-remote.
323
+ if (guardServer) {
324
+ await new Promise((resolve) => guardServer.close(resolve));
325
+ }
7
326
 
8
- import "stealthmole.mcp/bin/cli.mjs";
327
+ await import("mcp-remote/dist/proxy.js");
package/package.json CHANGED
@@ -1,19 +1,23 @@
1
1
  {
2
2
  "name": "@stealthmole/mcp",
3
- "version": "0.1.7",
4
- "description": "StealthMole Threat Intelligence MCP client — alias package for stealthmole.mcp",
3
+ "version": "0.1.8",
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",
7
7
  "stealthmole.mcp": "./bin/cli.mjs"
8
8
  },
9
+ "scripts": {
10
+ "postinstall": "node scripts/patch-oauth-callback.mjs"
11
+ },
9
12
  "type": "module",
10
13
  "license": "MIT",
11
14
  "homepage": "https://www.stealthmole.com",
12
15
  "files": [
13
- "bin/"
16
+ "bin/",
17
+ "scripts/"
14
18
  ],
15
19
  "dependencies": {
16
- "stealthmole.mcp": "0.1.7"
20
+ "mcp-remote": "0.1.38"
17
21
  },
18
22
  "engines": {
19
23
  "node": ">=18.0.0"
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Postinstall script that patches mcp-remote's OAuth callback page
5
+ * to redirect to https://www.stealthmole.com/ after successful authentication,
6
+ * instead of showing a "close this window" message.
7
+ *
8
+ * Safety:
9
+ * - Uses exact string matching (not greedy regex) to minimize unintended replacements
10
+ * - Verifies the result still contains the auth-code-received event emission
11
+ * - Non-fatal: CLI works normally even if patching fails
12
+ */
13
+
14
+ import { readdirSync, readFileSync, writeFileSync } from "fs";
15
+ import { join, dirname } from "path";
16
+ import { fileURLToPath } from "url";
17
+ import { createRequire } from "module";
18
+
19
+ // pnpm 호환: require.resolve로 mcp-remote의 실제 위치를 찾음
20
+ // (pnpm은 node_modules를 symlink 구조로 관리하므로 상대 경로가 동작하지 않음)
21
+ const require = createRequire(import.meta.url);
22
+ const distDir = join(dirname(require.resolve("mcp-remote/package.json")), "dist");
23
+
24
+ // Exact string that identifies the callback response in mcp-remote
25
+ const OLD_RESPONSE = `res.send(\`
26
+ Authorization successful!
27
+ You may close this window and return to the CLI.
28
+ <script>
29
+ // If this is a non-interactive session (no manual approval step was required) then
30
+ // this should automatically close the window. If not, this will have no effect and
31
+ // the user will see the message above.
32
+ window.close();
33
+ </script>
34
+ \`)`;
35
+
36
+ const NEW_HTML = [
37
+ "<!DOCTYPE html>",
38
+ '<html lang="en">',
39
+ "<head>",
40
+ ' <meta charset="utf-8">',
41
+ ' <meta http-equiv="refresh" content="3;url=https://www.stealthmole.com/">',
42
+ " <title>Authorization Successful - StealthMole</title>",
43
+ " <style>",
44
+ " body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0}",
45
+ " .card{text-align:center;padding:2.5rem 3rem;background:#1e293b;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.3)}",
46
+ " h1{font-size:1.4rem;margin:0 0 .8rem;color:#34d399}",
47
+ " p{margin:0;font-size:.95rem;color:#94a3b8}",
48
+ " span{color:#34d399;font-weight:600}",
49
+ " </style>",
50
+ "</head>",
51
+ "<body>",
52
+ ' <div class="card">',
53
+ " <h1>Authorization Successful</h1>",
54
+ ' <p>Redirecting to StealthMole in <span id="c">3</span>s\\u2026</p>',
55
+ " </div>",
56
+ " <script>",
57
+ ' var t=3,e=document.getElementById("c"),i=setInterval(function(){if(--t<=0){clearInterval(i);location.href="https://www.stealthmole.com/"}e.textContent=t},1e3);',
58
+ " </script>",
59
+ "</body>",
60
+ "</html>",
61
+ ].join("\n");
62
+
63
+ const NEW_RESPONSE = "res.send(`" + NEW_HTML + "`)";
64
+
65
+ // Post-patch integrity marker: this line must exist after the patched res.send()
66
+ const INTEGRITY_MARKER = 'options.events.emit("auth-code-received"';
67
+
68
+ try {
69
+ const files = readdirSync(distDir).filter((f) => f.endsWith(".js"));
70
+ let patched = false;
71
+
72
+ for (const file of files) {
73
+ const filePath = join(distDir, file);
74
+ const content = readFileSync(filePath, "utf-8");
75
+
76
+ if (!content.includes(OLD_RESPONSE)) continue;
77
+
78
+ const newContent = content.replace(OLD_RESPONSE, NEW_RESPONSE);
79
+
80
+ // Verify: the replacement should change exactly one occurrence
81
+ if (newContent === content) continue;
82
+
83
+ // Integrity check: auth-code-received event must still be present
84
+ if (!newContent.includes(INTEGRITY_MARKER)) {
85
+ console.error(
86
+ `[stealthmole.mcp] Patch integrity check failed for ${file}, skipping.`
87
+ );
88
+ continue;
89
+ }
90
+
91
+ writeFileSync(filePath, newContent, "utf-8");
92
+ console.error(
93
+ `[stealthmole.mcp] Patched OAuth callback page in ${file}`
94
+ );
95
+ patched = true;
96
+ }
97
+
98
+ if (!patched) {
99
+ console.error(
100
+ "[stealthmole.mcp] OAuth callback page already patched or pattern not found."
101
+ );
102
+ }
103
+ } catch {
104
+ // Non-fatal: if patching fails the CLI still works, just shows the default page
105
+ console.error(
106
+ "[stealthmole.mcp] Could not patch OAuth callback page."
107
+ );
108
+ }