@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 +322 -3
- package/package.json +8 -4
- package/scripts/patch-oauth-callback.mjs +108 -0
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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.
|
|
4
|
-
"description": "StealthMole Threat Intelligence MCP client
|
|
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
|
-
"
|
|
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
|
+
}
|