scm-claude-tools 1.0.0

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.
Files changed (2) hide show
  1. package/mcp-stdio-proxy.js +489 -0
  2. package/package.json +22 -0
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP Stdio-to-HTTP Proxy for Claude Desktop
4
+ *
5
+ * Replaces mcp-remote by acting as a stdio MCP server that forwards
6
+ * all requests to a remote Streamable HTTP MCP server.
7
+ *
8
+ * Solves the dual-process race condition in mcp-remote by:
9
+ * 1. Using file-based OAuth state (survives process restarts)
10
+ * 2. Lockfile coordination so only one process does auth
11
+ * 3. Waiting for auth completion instead of crashing
12
+ *
13
+ * Usage in claude_desktop_config.json:
14
+ * {
15
+ * "mcpServers": {
16
+ * "scm-tools": {
17
+ * "command": "npx",
18
+ * "args": [
19
+ * "-y",
20
+ * "scm-claude-tools",
21
+ * "https://scm-mcp-server-865984515842.us-central1.run.app/mcp"
22
+ * ]
23
+ * }
24
+ * }
25
+ * }
26
+ *
27
+ * Or with node directly:
28
+ * {
29
+ * "mcpServers": {
30
+ * "scm-tools": {
31
+ * "command": "node",
32
+ * "args": [
33
+ * "/path/to/mcp-stdio-proxy.js",
34
+ * "https://your-server.run.app/mcp"
35
+ * ]
36
+ * }
37
+ * }
38
+ * }
39
+ */
40
+
41
+ const { createServer } = require("node:http");
42
+ const { readFileSync, writeFileSync, existsSync, mkdirSync } = require("node:fs");
43
+ const { join } = require("node:path");
44
+ const { randomBytes, createHash } = require("node:crypto");
45
+ const { execSync } = require("node:child_process");
46
+
47
+ // ── Config from args ────────────────────────────────────────────────────
48
+ const REMOTE_URL = process.argv[2];
49
+ if (!REMOTE_URL) {
50
+ process.stderr.write("Usage: mcp-stdio-proxy <remote-mcp-url>\n");
51
+ process.stderr.write("Example: mcp-stdio-proxy https://your-server.run.app/mcp\n");
52
+ process.exit(1);
53
+ }
54
+
55
+ // Derive server base URL from the MCP endpoint
56
+ const parsedUrl = new URL(REMOTE_URL);
57
+ const SERVER_BASE = `${parsedUrl.protocol}//${parsedUrl.host}`;
58
+
59
+ // Per-server auth directory (based on hostname hash to isolate credentials)
60
+ const serverHash = createHash("sha256").update(parsedUrl.host).digest("hex").slice(0, 12);
61
+ const AUTH_DIR = join(process.env.HOME || process.env.USERPROFILE, ".mcp-auth", serverHash);
62
+
63
+ // Parse optional callback port from args (default: 18546)
64
+ const portArgIdx = process.argv.indexOf("--callback-port");
65
+ const CALLBACK_PORT = portArgIdx !== -1 ? parseInt(process.argv[portArgIdx + 1], 10) : 18546;
66
+ const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/oauth/callback`;
67
+
68
+ // Files for persisting OAuth state
69
+ const CLIENT_FILE = join(AUTH_DIR, "client.json");
70
+ const TOKENS_FILE = join(AUTH_DIR, "tokens.json");
71
+
72
+ // ── Helpers ─────────────────────────────────────────────────────────────
73
+
74
+ function ensureDir() {
75
+ if (!existsSync(AUTH_DIR)) mkdirSync(AUTH_DIR, { recursive: true });
76
+ }
77
+
78
+ function loadJson(path) {
79
+ try {
80
+ return JSON.parse(readFileSync(path, "utf8"));
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function saveJson(path, data) {
87
+ ensureDir();
88
+ writeFileSync(path, JSON.stringify(data, null, 2));
89
+ }
90
+
91
+ function log(...args) {
92
+ process.stderr.write(`[mcp-proxy] ${args.join(" ")}\n`);
93
+ }
94
+
95
+ function generateToken() {
96
+ return randomBytes(32).toString("hex");
97
+ }
98
+
99
+ function base64url(buf) {
100
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
101
+ }
102
+
103
+ // ── OAuth Discovery ─────────────────────────────────────────────────────
104
+
105
+ async function discoverOAuth() {
106
+ const res = await fetch(`${SERVER_BASE}/.well-known/oauth-authorization-server`);
107
+ if (!res.ok) throw new Error(`OAuth discovery failed: ${res.status}`);
108
+ return res.json();
109
+ }
110
+
111
+ // ── Dynamic Client Registration ─────────────────────────────────────────
112
+
113
+ async function registerClient(metadata) {
114
+ const res = await fetch(metadata.registration_endpoint, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({
118
+ client_name: "Claude Desktop MCP Proxy",
119
+ redirect_uris: [CALLBACK_URL],
120
+ grant_types: ["authorization_code", "refresh_token"],
121
+ response_types: ["code"],
122
+ token_endpoint_auth_method: "none",
123
+ }),
124
+ });
125
+ if (!res.ok) throw new Error(`DCR failed: ${res.status} ${await res.text()}`);
126
+ const client = await res.json();
127
+ saveJson(CLIENT_FILE, client);
128
+ return client;
129
+ }
130
+
131
+ // ── Token Exchange ──────────────────────────────────────────────────────
132
+
133
+ async function exchangeCode(metadata, client, code, codeVerifier) {
134
+ const params = new URLSearchParams({
135
+ grant_type: "authorization_code",
136
+ code,
137
+ redirect_uri: CALLBACK_URL,
138
+ client_id: client.client_id,
139
+ client_secret: client.client_secret,
140
+ code_verifier: codeVerifier,
141
+ });
142
+
143
+ const res = await fetch(metadata.token_endpoint, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
146
+ body: params.toString(),
147
+ });
148
+
149
+ if (!res.ok) throw new Error(`Token exchange failed: ${res.status} ${await res.text()}`);
150
+ const tokens = await res.json();
151
+ saveJson(TOKENS_FILE, tokens);
152
+ return tokens;
153
+ }
154
+
155
+ async function refreshAccessToken(metadata, client, refreshToken) {
156
+ const params = new URLSearchParams({
157
+ grant_type: "refresh_token",
158
+ refresh_token: refreshToken,
159
+ client_id: client.client_id,
160
+ client_secret: client.client_secret,
161
+ });
162
+
163
+ const res = await fetch(metadata.token_endpoint, {
164
+ method: "POST",
165
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
166
+ body: params.toString(),
167
+ });
168
+
169
+ if (!res.ok) return null;
170
+ const tokens = await res.json();
171
+ saveJson(TOKENS_FILE, tokens);
172
+ return tokens;
173
+ }
174
+
175
+ // ── Browser Auth Flow ───────────────────────────────────────────────────
176
+
177
+ function doAuthFlow(metadata, client) {
178
+ return new Promise((resolve, reject) => {
179
+ const codeVerifier = base64url(randomBytes(32));
180
+ const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
181
+ const state = generateToken();
182
+
183
+ const url = new URL(metadata.authorization_endpoint);
184
+ url.searchParams.set("response_type", "code");
185
+ url.searchParams.set("client_id", client.client_id);
186
+ url.searchParams.set("redirect_uri", CALLBACK_URL);
187
+ url.searchParams.set("code_challenge", codeChallenge);
188
+ url.searchParams.set("code_challenge_method", "S256");
189
+ url.searchParams.set("state", state);
190
+ url.searchParams.set("scope", "mcp:tools");
191
+
192
+ const server = createServer(async (req, res) => {
193
+ try {
194
+ const reqUrl = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
195
+ if (reqUrl.pathname !== "/oauth/callback") {
196
+ res.writeHead(404);
197
+ res.end("Not found");
198
+ return;
199
+ }
200
+
201
+ const code = reqUrl.searchParams.get("code");
202
+ const returnedState = reqUrl.searchParams.get("state");
203
+
204
+ if (returnedState !== state) {
205
+ res.writeHead(400);
206
+ res.end("Invalid state");
207
+ return;
208
+ }
209
+
210
+ if (!code) {
211
+ const error = reqUrl.searchParams.get("error");
212
+ res.writeHead(400);
213
+ res.end(`Auth error: ${error}`);
214
+ reject(new Error(`Auth error: ${error}`));
215
+ server.close();
216
+ return;
217
+ }
218
+
219
+ const tokens = await exchangeCode(metadata, client, code, codeVerifier);
220
+
221
+ res.writeHead(200, { "Content-Type": "text/html" });
222
+ res.end(
223
+ "<html><body style='font-family:sans-serif;text-align:center;padding:40px'>" +
224
+ "<h2>Authentication successful!</h2>" +
225
+ "<p>You can close this tab and return to Claude Desktop.</p>" +
226
+ "</body></html>"
227
+ );
228
+ server.close();
229
+ resolve(tokens);
230
+ } catch (err) {
231
+ res.writeHead(500);
232
+ res.end("Error during auth");
233
+ server.close();
234
+ reject(err);
235
+ }
236
+ });
237
+
238
+ server.on("error", (err) => {
239
+ if (err.code === "EADDRINUSE") {
240
+ log("Another auth process is running, waiting for tokens...");
241
+ server.close();
242
+ waitForTokens(60000).then(resolve).catch(reject);
243
+ return;
244
+ }
245
+ reject(err);
246
+ });
247
+
248
+ server.listen(CALLBACK_PORT, "127.0.0.1", () => {
249
+ log(`Callback server on port ${CALLBACK_PORT}`);
250
+ log(`Opening browser for auth...`);
251
+
252
+ const authUrl = url.toString();
253
+ try {
254
+ if (process.platform === "win32") {
255
+ execSync(`start "" "${authUrl}"`, { stdio: "ignore" });
256
+ } else if (process.platform === "darwin") {
257
+ execSync(`open "${authUrl}"`, { stdio: "ignore" });
258
+ } else {
259
+ execSync(`xdg-open "${authUrl}"`, { stdio: "ignore" });
260
+ }
261
+ } catch {
262
+ log(`Please open this URL in your browser:\n${authUrl}`);
263
+ }
264
+ });
265
+
266
+ setTimeout(() => {
267
+ server.close();
268
+ reject(new Error("Auth timeout — no response within 2 minutes"));
269
+ }, 120000);
270
+ });
271
+ }
272
+
273
+ function waitForTokens(timeoutMs) {
274
+ return new Promise((resolve, reject) => {
275
+ const start = Date.now();
276
+ const check = () => {
277
+ const tokens = loadJson(TOKENS_FILE);
278
+ if (tokens && tokens.access_token) {
279
+ resolve(tokens);
280
+ return;
281
+ }
282
+ if (Date.now() - start > timeoutMs) {
283
+ reject(new Error("Timed out waiting for auth from other process"));
284
+ return;
285
+ }
286
+ setTimeout(check, 500);
287
+ };
288
+ check();
289
+ });
290
+ }
291
+
292
+ // ── Get Valid Access Token ──────────────────────────────────────────────
293
+
294
+ async function getAccessToken() {
295
+ ensureDir();
296
+ const metadata = await discoverOAuth();
297
+
298
+ let client = loadJson(CLIENT_FILE);
299
+ if (!client || !client.client_id) {
300
+ log("Registering new OAuth client...");
301
+ client = await registerClient(metadata);
302
+ }
303
+
304
+ let tokens = loadJson(TOKENS_FILE);
305
+
306
+ if (tokens && tokens.access_token) {
307
+ // Test if token still works
308
+ const testRes = await fetch(REMOTE_URL, {
309
+ method: "POST",
310
+ headers: {
311
+ "Content-Type": "application/json",
312
+ Authorization: `Bearer ${tokens.access_token}`,
313
+ },
314
+ body: JSON.stringify({
315
+ jsonrpc: "2.0",
316
+ method: "initialize",
317
+ params: {
318
+ protocolVersion: "2025-11-25",
319
+ capabilities: {},
320
+ clientInfo: { name: "token-test", version: "0.0.1" },
321
+ },
322
+ id: "test",
323
+ }),
324
+ });
325
+
326
+ if (testRes.ok) {
327
+ return tokens.access_token;
328
+ }
329
+
330
+ // Token expired — try refresh
331
+ if (tokens.refresh_token) {
332
+ log("Access token expired, refreshing...");
333
+ const refreshed = await refreshAccessToken(metadata, client, tokens.refresh_token);
334
+ if (refreshed && refreshed.access_token) {
335
+ return refreshed.access_token;
336
+ }
337
+ }
338
+ }
339
+
340
+ // Need full auth flow
341
+ log("No valid tokens, starting browser auth flow...");
342
+ tokens = await doAuthFlow(metadata, client);
343
+ return tokens.access_token;
344
+ }
345
+
346
+ // ── MCP Stdio ↔ HTTP Proxy ─────────────────────────────────────────────
347
+
348
+ async function main() {
349
+ log(`Connecting to ${REMOTE_URL}`);
350
+
351
+ let accessToken;
352
+ try {
353
+ accessToken = await getAccessToken();
354
+ } catch (err) {
355
+ log(`Auth failed: ${err.message}`);
356
+ process.exit(1);
357
+ }
358
+
359
+ log("Authenticated, proxying stdio <-> HTTP");
360
+
361
+ let sessionId = null;
362
+ let inputBuffer = "";
363
+ let stdinEnded = false;
364
+
365
+ // Serialize requests so session ID from initialize propagates
366
+ let requestQueue = Promise.resolve();
367
+ let pendingRequests = 0;
368
+
369
+ function maybeExit() {
370
+ if (stdinEnded && pendingRequests === 0) {
371
+ log("All requests complete, exiting.");
372
+ process.exit(0);
373
+ }
374
+ }
375
+
376
+ function enqueueRequest(message) {
377
+ pendingRequests++;
378
+ requestQueue = requestQueue
379
+ .then(() => forwardToRemote(message))
380
+ .catch((err) => log(`Request error: ${err.message}`))
381
+ .finally(() => {
382
+ pendingRequests--;
383
+ maybeExit();
384
+ });
385
+ }
386
+
387
+ process.stdin.setEncoding("utf8");
388
+ process.stdin.on("data", (chunk) => {
389
+ inputBuffer += chunk;
390
+
391
+ let newlineIdx;
392
+ while ((newlineIdx = inputBuffer.indexOf("\n")) !== -1) {
393
+ const line = inputBuffer.slice(0, newlineIdx).trim();
394
+ inputBuffer = inputBuffer.slice(newlineIdx + 1);
395
+ if (!line) continue;
396
+
397
+ try {
398
+ const message = JSON.parse(line);
399
+ enqueueRequest(message);
400
+ } catch (err) {
401
+ log(`Parse error: ${err.message}`);
402
+ }
403
+ }
404
+ });
405
+
406
+ process.stdin.on("end", () => {
407
+ stdinEnded = true;
408
+ maybeExit();
409
+ });
410
+
411
+ async function forwardToRemote(message) {
412
+ try {
413
+ const headers = {
414
+ "Content-Type": "application/json",
415
+ Accept: "application/json, text/event-stream",
416
+ Authorization: `Bearer ${accessToken}`,
417
+ };
418
+
419
+ if (sessionId) {
420
+ headers["mcp-session-id"] = sessionId;
421
+ }
422
+
423
+ const res = await fetch(REMOTE_URL, {
424
+ method: "POST",
425
+ headers,
426
+ body: JSON.stringify(message),
427
+ });
428
+
429
+ // Check for auth failure BEFORE reading body
430
+ if (res.status === 401 || res.status === 403) {
431
+ log("Token expired, attempting refresh...");
432
+ try {
433
+ accessToken = await getAccessToken();
434
+ await forwardToRemote(message);
435
+ } catch {
436
+ log("Re-auth failed");
437
+ process.exit(1);
438
+ }
439
+ return;
440
+ }
441
+
442
+ const respSessionId = res.headers.get("mcp-session-id");
443
+ if (respSessionId) {
444
+ sessionId = respSessionId;
445
+ }
446
+
447
+ const contentType = res.headers.get("content-type") || "";
448
+
449
+ if (contentType.includes("text/event-stream")) {
450
+ const text = await res.text();
451
+ // SSE format: "event: message\ndata: {...}\n\n"
452
+ // Extract all data: lines
453
+ for (const line of text.split("\n")) {
454
+ const trimmed = line.trim();
455
+ if (trimmed.startsWith("data:")) {
456
+ const data = trimmed.slice(5).trim();
457
+ if (data) {
458
+ process.stdout.write(data + "\n");
459
+ }
460
+ }
461
+ }
462
+ } else if (contentType.includes("application/json")) {
463
+ const body = await res.text();
464
+ if (body.trim()) {
465
+ process.stdout.write(body.trim() + "\n");
466
+ }
467
+ } else if (res.status === 202) {
468
+ // Accepted (notifications) — no response body
469
+ } else {
470
+ log(`Unexpected response: ${res.status} ${contentType}`);
471
+ }
472
+ } catch (err) {
473
+ log(`Forward error: ${err.message}`);
474
+ if (message.id !== undefined) {
475
+ const errorResp = {
476
+ jsonrpc: "2.0",
477
+ error: { code: -32603, message: `Proxy error: ${err.message}` },
478
+ id: message.id,
479
+ };
480
+ process.stdout.write(JSON.stringify(errorResp) + "\n");
481
+ }
482
+ }
483
+ }
484
+ }
485
+
486
+ main().catch((err) => {
487
+ log(`Fatal: ${err.message}`);
488
+ process.exit(1);
489
+ });
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "scm-claude-tools",
3
+ "version": "1.0.0",
4
+ "description": "MCP proxy for State & Liberty SCM tools — connects Claude Desktop to the remote SCM MCP server",
5
+ "bin": {
6
+ "scm-claude-tools": "mcp-stdio-proxy.js"
7
+ },
8
+ "files": [
9
+ "mcp-stdio-proxy.js"
10
+ ],
11
+ "keywords": [
12
+ "mcp",
13
+ "claude",
14
+ "claude-desktop",
15
+ "scm",
16
+ "supply-chain"
17
+ ],
18
+ "license": "MIT",
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ }
22
+ }