@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.
- package/bin/claude-desktop-config.mjs +201 -0
- package/bin/cli.mjs +18 -110
- package/bin/token-cache-shim.mjs +162 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 (
|
|
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