UNPKG

mcp-walichat

Version:
976 lines (969 loc) 36.1 kB
// package.json var version = "0.1.2"; // src/lib/utils.ts import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { OAuthError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import { OAuthClientInformationFullSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; // src/lib/mcp-auth-config.ts import path from "path"; import os from "os"; import fs from "fs/promises"; async function createLockfile(serverUrlHash, pid2, port) { const lockData = { pid: pid2, port, timestamp: Date.now() }; await writeJsonFile(serverUrlHash, "lock.json", lockData); } async function checkLockfile(serverUrlHash) { try { const lockfile = await readJsonFile(serverUrlHash, "lock.json", { async parseAsync(data) { if (typeof data !== "object" || data === null) return null; if (typeof data.pid !== "number" || typeof data.port !== "number" || typeof data.timestamp !== "number") { return null; } return data; } }); return lockfile || null; } catch { return null; } } async function deleteLockfile(serverUrlHash) { await deleteConfigFile(serverUrlHash, "lock.json"); } function getConfigDir() { const baseConfigDir = process.env.MCP_REMOTE_CONFIG_DIR || path.join(os.homedir(), ".mcp-auth"); return path.join(baseConfigDir, `mcp-remote-${version}`); } async function ensureConfigDir() { try { const configDir = getConfigDir(); await fs.mkdir(configDir, { recursive: true }); } catch (error) { log("Error creating config directory:", error); throw error; } } function getConfigFilePath(serverUrlHash, filename) { const configDir = getConfigDir(); return path.join(configDir, `${serverUrlHash}_${filename}`); } async function deleteConfigFile(serverUrlHash, filename) { try { const filePath = getConfigFilePath(serverUrlHash, filename); await fs.unlink(filePath); } catch (error) { if (error.code !== "ENOENT") { log(`Error deleting ${filename}:`, error); } } } async function readJsonFile(serverUrlHash, filename, schema) { try { await ensureConfigDir(); const filePath = getConfigFilePath(serverUrlHash, filename); const content = await fs.readFile(filePath, "utf-8"); const result = await schema.parseAsync(JSON.parse(content)); return result; } catch (error) { if (error.code === "ENOENT") { return void 0; } log(`Error reading ${filename}:`, error); return void 0; } } async function writeJsonFile(serverUrlHash, filename, data) { try { await ensureConfigDir(); const filePath = getConfigFilePath(serverUrlHash, filename); await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8"); } catch (error) { log(`Error writing ${filename}:`, error); throw error; } } async function readTextFile(serverUrlHash, filename, errorMessage) { try { await ensureConfigDir(); const filePath = getConfigFilePath(serverUrlHash, filename); return await fs.readFile(filePath, "utf-8"); } catch (error) { throw new Error(errorMessage || `Error reading ${filename}`); } } async function writeTextFile(serverUrlHash, filename, text) { try { await ensureConfigDir(); const filePath = getConfigFilePath(serverUrlHash, filename); await fs.writeFile(filePath, text, "utf-8"); } catch (error) { log(`Error writing ${filename}:`, error); throw error; } } // src/lib/utils.ts import express from "express"; import net from "net"; import crypto from "crypto"; import fs2 from "fs"; import { readFile, rm } from "fs/promises"; import path2 from "path"; var REASON_AUTH_NEEDED = "authentication-needed"; var REASON_TRANSPORT_FALLBACK = "falling-back-to-alternate-transport"; var pid = process.pid; var DEBUG = false; function getTimestamp() { const now = /* @__PURE__ */ new Date(); return now.toISOString(); } function debugLog(message, ...args) { if (!DEBUG) return; const serverUrlHash = global.currentServerUrlHash; if (!serverUrlHash) { console.error("[DEBUG LOG ERROR] global.currentServerUrlHash is not set. Cannot write debug log."); return; } try { const formattedMessage = `[${getTimestamp()}][${pid}] ${message}`; console.error(formattedMessage, ...args); const configDir = getConfigDir(); fs2.mkdirSync(configDir, { recursive: true }); const logPath = path2.join(configDir, `${serverUrlHash}_debug.log`); const logMessage = `${formattedMessage} ${args.map((arg) => typeof arg === "object" ? JSON.stringify(arg) : String(arg)).join(" ")} `; fs2.appendFileSync(logPath, logMessage, { encoding: "utf8" }); } catch (error) { console.error(`[DEBUG LOG ERROR] ${error}`); } } function log(str, ...rest) { console.error(`[${pid}] ${str}`, ...rest); if (DEBUG && global.currentServerUrlHash) { debugLog(str, ...rest); } } function mcpProxy({ transportToClient, transportToServer }) { let transportToClientClosed = false; let transportToServerClosed = false; transportToClient.onmessage = (_message) => { const message = _message; log("[Local\u2192Remote]", message.method || message.id); if (DEBUG) { debugLog("Local \u2192 Remote message", { method: message.method, id: message.id, params: message.params ? JSON.stringify(message.params).substring(0, 500) : void 0 }); } if (message.method === "initialize") { const { clientInfo } = message.params; if (clientInfo) clientInfo.name = `${clientInfo.name} (via mcp-remote ${version})`; log(JSON.stringify(message, null, 2)); if (DEBUG) { debugLog("Initialize message with modified client info", { clientInfo }); } } transportToServer.send(message).catch(onServerError); }; transportToServer.onmessage = (_message) => { const message = _message; log("[Remote\u2192Local]", message.method || message.id); if (DEBUG) { debugLog("Remote \u2192 Local message", { method: message.method, id: message.id, result: message.result ? "result-present" : void 0, error: message.error }); } transportToClient.send(message).catch(onClientError); }; transportToClient.onclose = () => { if (transportToServerClosed) { return; } transportToClientClosed = true; if (DEBUG) debugLog("Local transport closed, closing remote transport"); transportToServer.close().catch(onServerError); }; transportToServer.onclose = () => { if (transportToClientClosed) { return; } transportToServerClosed = true; if (DEBUG) debugLog("Remote transport closed, closing local transport"); transportToClient.close().catch(onClientError); }; transportToClient.onerror = onClientError; transportToServer.onerror = onServerError; function onClientError(error) { log("Error from local client:", error); if (DEBUG) debugLog("Error from local client", { stack: error.stack }); } function onServerError(error) { log("Error from remote server:", error); if (DEBUG) debugLog("Error from remote server", { stack: error.stack }); } } async function connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy = "http-first", recursionReasons = /* @__PURE__ */ new Set()) { log(`[${pid}] Connecting to remote server: ${serverUrl}`); const url = new URL(serverUrl); const eventSourceInit = { fetch: (url2, init) => { return Promise.resolve(authProvider?.tokens?.()).then( (tokens) => fetch(url2, { ...init, headers: { ...init?.headers, ...headers, ...tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}, Accept: "text/event-stream" } }) ); } }; log(`Using transport strategy: ${transportStrategy}`); const shouldAttemptFallback = transportStrategy === "http-first" || transportStrategy === "sse-first"; const sseTransport = transportStrategy === "sse-only" || transportStrategy === "sse-first"; const transport = sseTransport ? new SSEClientTransport(url, { authProvider, requestInit: { headers }, eventSourceInit }) : new StreamableHTTPClientTransport(url, { authProvider, requestInit: { headers } }); try { if (DEBUG) debugLog("Attempting to connect to remote server", { sseTransport }); if (client) { if (DEBUG) debugLog("Connecting client to transport"); await client.connect(transport); } else { if (DEBUG) debugLog("Starting transport directly"); await transport.start(); if (!sseTransport) { if (DEBUG) debugLog("Creating test transport for HTTP-only connection test"); const testTransport = new StreamableHTTPClientTransport(url, { authProvider, requestInit: { headers } }); const testClient = new Client({ name: "mcp-remote-fallback-test", version: "0.0.0" }, { capabilities: {} }); await testClient.connect(testTransport); } } log(`Connected to remote server using ${transport.constructor.name}`); return transport; } catch (error) { if (error instanceof Error && shouldAttemptFallback && (error.message.includes("405") || error.message.includes("Method Not Allowed") || error.message.includes("404") || error.message.includes("Not Found"))) { log(`Received error: ${error.message}`); if (recursionReasons.has(REASON_TRANSPORT_FALLBACK)) { const errorMessage = `Already attempted transport fallback. Giving up.`; log(errorMessage); throw new Error(errorMessage); } log(`Recursively reconnecting for reason: ${REASON_TRANSPORT_FALLBACK}`); recursionReasons.add(REASON_TRANSPORT_FALLBACK); return connectToRemoteServer( client, serverUrl, authProvider, headers, authInitializer, sseTransport ? "http-only" : "sse-only", recursionReasons ); } else if (error instanceof UnauthorizedError || error instanceof Error && error.message.includes("Unauthorized")) { log("Authentication required. Initializing auth..."); if (DEBUG) { debugLog("Authentication error detected", { errorCode: error instanceof OAuthError ? error.errorCode : void 0, errorMessage: error.message, stack: error.stack }); } if (DEBUG) debugLog("Calling authInitializer to start auth flow"); const { waitForAuthCode, skipBrowserAuth } = await authInitializer(); if (skipBrowserAuth) { log("Authentication required but skipping browser auth - using shared auth"); } else { log("Authentication required. Waiting for authorization..."); } if (DEBUG) debugLog("Waiting for auth code from callback server"); const code = await waitForAuthCode(); if (DEBUG) debugLog("Received auth code from callback server"); try { log("Completing authorization..."); await transport.finishAuth(code); if (DEBUG) debugLog("Authorization completed successfully"); if (recursionReasons.has(REASON_AUTH_NEEDED)) { const errorMessage = `Already attempted reconnection for reason: ${REASON_AUTH_NEEDED}. Giving up.`; log(errorMessage); if (DEBUG) debugLog("Already attempted auth reconnection, giving up", { recursionReasons: Array.from(recursionReasons) }); throw new Error(errorMessage); } recursionReasons.add(REASON_AUTH_NEEDED); log(`Recursively reconnecting for reason: ${REASON_AUTH_NEEDED}`); if (DEBUG) debugLog("Recursively reconnecting after auth", { recursionReasons: Array.from(recursionReasons) }); return connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy, recursionReasons); } catch (authError) { log("Authorization error:", authError); if (DEBUG) debugLog("Authorization error during finishAuth", { errorMessage: authError.message, stack: authError.stack }); throw authError; } } else { log("Connection error:", error); if (DEBUG) debugLog("Connection error", { errorMessage: error.message, stack: error.stack, transportType: transport.constructor.name }); throw error; } } } function setupOAuthCallbackServerWithLongPoll(options) { let authCode = null; const app = express(); let authCompletedResolve; const authCompletedPromise = new Promise((resolve) => { authCompletedResolve = resolve; }); app.get("/wait-for-auth", (req, res) => { if (authCode) { log("Auth already completed, returning 200"); res.status(200).send("Authentication completed"); return; } if (req.query.poll === "false") { log("Client requested no long poll, responding with 202"); res.status(202).send("Authentication in progress"); return; } const longPollTimeout = setTimeout(() => { log("Long poll timeout reached, responding with 202"); res.status(202).send("Authentication in progress"); }, 3e4); authCompletedPromise.then(() => { clearTimeout(longPollTimeout); if (!res.headersSent) { log("Auth completed during long poll, responding with 200"); res.status(200).send("Authentication completed"); } }).catch(() => { clearTimeout(longPollTimeout); if (!res.headersSent) { log("Auth failed during long poll, responding with 500"); res.status(500).send("Authentication failed"); } }); }); app.get(options.path, (req, res) => { const code = req.query.code; if (!code) { res.status(400).send("Error: No authorization code received"); return; } authCode = code; log("Auth code received, resolving promise"); authCompletedResolve(code); res.send(` Authorization successful! You may close this window and return to the CLI. <script> // If this is a non-interactive session (no manual approval step was required) then // this should automatically close the window. If not, this will have no effect and // the user will see the message above. window.close(); </script> `); options.events.emit("auth-code-received", code); }); const server = app.listen(options.port, () => { log(`OAuth callback server running at http://127.0.0.1:${options.port}`); }); const waitForAuthCode = () => { return new Promise((resolve) => { if (authCode) { resolve(authCode); return; } options.events.once("auth-code-received", (code) => { resolve(code); }); }); }; return { server, authCode, waitForAuthCode, authCompletedPromise }; } async function findExistingClientPort(serverUrlHash) { const clientInfo = await readJsonFile(serverUrlHash, "client_info.json", OAuthClientInformationFullSchema); if (!clientInfo) { return void 0; } const localhostRedirectUri = clientInfo.redirect_uris.map((uri) => new URL(uri)).find(({ hostname }) => hostname === "localhost" || hostname === "127.0.0.1"); if (!localhostRedirectUri) { throw new Error("Cannot find localhost callback URI from existing client information"); } return parseInt(localhostRedirectUri.port); } function calculateDefaultPort(serverUrlHash) { const offset = parseInt(serverUrlHash.substring(0, 4), 16); return 3335 + offset % 45816; } async function findAvailablePort(preferredPort) { return new Promise((resolve, reject) => { const server = net.createServer(); server.on("error", (err) => { if (err.code === "EADDRINUSE") { server.listen(0); } else { reject(err); } }); server.on("listening", () => { const { port } = server.address(); server.close(() => { resolve(port); }); }); server.listen(preferredPort || 0); }); } async function parseCommandLineArgs(args, usage) { const headers = {}; let i = 0; while (i < args.length) { if (args[i] === "--header" && i < args.length - 1) { const value = args[i + 1]; const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/); if (match) { headers[match[1]] = match[2]; } else { log(`Warning: ignoring invalid header argument: ${value}`); } args.splice(i, 2); continue; } i++; } const apiKey = args[0]; const specifiedPort = args[1] ? parseInt(args[1]) : void 0; const allowHttp = args.includes("--allow-http"); const debug = args.includes("--debug"); if (debug) { DEBUG = true; log("Debug mode enabled - detailed logs will be written to ~/.mcp-auth/"); } let transportStrategy = "http-first"; const transportIndex = args.indexOf("--transport"); if (transportIndex !== -1 && transportIndex < args.length - 1) { const strategy = args[transportIndex + 1]; if (strategy === "sse-only" || strategy === "http-only" || strategy === "sse-first" || strategy === "http-first") { transportStrategy = strategy; log(`Using transport strategy: ${transportStrategy}`); } else { log(`Warning: Ignoring invalid transport strategy: ${strategy}. Valid values are: sse-only, http-only, sse-first, http-first`); } } let host = "localhost"; const hostIndex = args.indexOf("--host"); if (hostIndex !== -1 && hostIndex < args.length - 1) { host = args[hostIndex + 1]; log(`Using callback hostname: ${host}`); } let staticOAuthClientMetadata = null; const staticOAuthClientMetadataIndex = args.indexOf("--static-oauth-client-metadata"); if (staticOAuthClientMetadataIndex !== -1 && staticOAuthClientMetadataIndex < args.length - 1) { const staticOAuthClientMetadataArg = args[staticOAuthClientMetadataIndex + 1]; if (staticOAuthClientMetadataArg.startsWith("@")) { const filePath = staticOAuthClientMetadataArg.slice(1); staticOAuthClientMetadata = JSON.parse(await readFile(filePath, "utf8")); log(`Using static OAuth client metadata from file: ${filePath}`); } else { staticOAuthClientMetadata = JSON.parse(staticOAuthClientMetadataArg); log(`Using static OAuth client metadata from string`); } } let staticOAuthClientInfo = null; const staticOAuthClientInfoIndex = args.indexOf("--static-oauth-client-info"); if (staticOAuthClientInfoIndex !== -1 && staticOAuthClientInfoIndex < args.length - 1) { const staticOAuthClientInfoArg = args[staticOAuthClientInfoIndex + 1]; if (staticOAuthClientInfoArg.startsWith("@")) { const filePath = staticOAuthClientInfoArg.slice(1); staticOAuthClientInfo = JSON.parse(await readFile(filePath, "utf8")); log(`Using static OAuth client information from file: ${filePath}`); } else { staticOAuthClientInfo = JSON.parse(staticOAuthClientInfoArg); log(`Using static OAuth client information from string`); } } if (!apiKey) { log(usage); process.exit(1); } const serverBaseUrl = process.env.WALICHAT_SERVER_URL || "https://api.wali.chat"; const serverUrl = `${serverBaseUrl}/mcp?key=${apiKey}`; const serverUrlHash = getServerUrlHash(serverUrl); global.currentServerUrlHash = serverUrlHash; if (DEBUG) { debugLog(`Starting mcp-remote with server URL: ${serverUrl}`); } const defaultPort = calculateDefaultPort(serverUrlHash); const [existingClientPort, availablePort] = await Promise.all([findExistingClientPort(serverUrlHash), findAvailablePort(defaultPort)]); let callbackPort; if (specifiedPort) { if (existingClientPort && specifiedPort !== existingClientPort) { log( `Warning! Specified callback port of ${specifiedPort}, which conflicts with existing client registration port ${existingClientPort}. Deleting existing client data to force reregistration.` ); await rm(getConfigFilePath(serverUrlHash, "client_info.json")); } log(`Using specified callback port: ${specifiedPort}`); callbackPort = specifiedPort; } else if (existingClientPort) { log(`Using existing client port: ${existingClientPort}`); callbackPort = existingClientPort; } else { log(`Using automatically selected callback port: ${availablePort}`); callbackPort = availablePort; } if (Object.keys(headers).length > 0) { log(`Using custom headers: ${JSON.stringify(headers)}`); } for (const [key, value] of Object.entries(headers)) { headers[key] = value.replace(/\$\{([^}]+)}/g, (match, envVarName) => { const envVarValue = process.env[envVarName]; if (envVarValue !== void 0) { log(`Replacing ${match} with environment value in header '${key}'`); return envVarValue; } else { log(`Warning: Environment variable '${envVarName}' not found for header '${key}'.`); return ""; } }); } return { apiKey, callbackPort, headers, transportStrategy, host, debug, staticOAuthClientMetadata, staticOAuthClientInfo }; } function setupSignalHandlers(cleanup) { process.on("SIGINT", async () => { log("\nShutting down..."); await cleanup(); process.exit(0); }); process.stdin.resume(); process.stdin.on("end", async () => { log("\nShutting down..."); await cleanup(); process.exit(0); }); } function getServerUrlHash(serverUrl) { return crypto.createHash("md5").update(serverUrl).digest("hex"); } // src/lib/node-oauth-client-provider.ts import open from "open"; import { OAuthClientInformationFullSchema as OAuthClientInformationFullSchema2, OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { randomUUID } from "node:crypto"; var NodeOAuthClientProvider = class { /** * Creates a new NodeOAuthClientProvider * @param options Configuration options for the provider */ constructor(options) { this.options = options; this.serverUrlHash = getServerUrlHash(options.serverUrl); this.callbackPath = options.callbackPath || "/oauth/callback"; this.clientName = options.clientName || "MCP CLI Client"; this.clientUri = options.clientUri || "https://github.com/modelcontextprotocol/mcp-cli"; this.softwareId = options.softwareId || "2e6dc280-f3c3-4e01-99a7-8181dbd1d23d"; this.softwareVersion = options.softwareVersion || version; this.staticOAuthClientMetadata = options.staticOAuthClientMetadata; this.staticOAuthClientInfo = options.staticOAuthClientInfo; this._state = randomUUID(); } serverUrlHash; callbackPath; clientName; clientUri; softwareId; softwareVersion; staticOAuthClientMetadata; staticOAuthClientInfo; _state; get redirectUrl() { return `http://${this.options.host}:${this.options.callbackPort}${this.callbackPath}`; } get clientMetadata() { return { redirect_uris: [this.redirectUrl], token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], client_name: this.clientName, client_uri: this.clientUri, software_id: this.softwareId, software_version: this.softwareVersion, ...this.staticOAuthClientMetadata }; } state() { return this._state; } /** * Gets the client information if it exists * @returns The client information or undefined */ async clientInformation() { if (DEBUG) debugLog("Reading client info"); if (this.staticOAuthClientInfo) { if (DEBUG) debugLog("Returning static client info"); return this.staticOAuthClientInfo; } const clientInfo = await readJsonFile( this.serverUrlHash, "client_info.json", OAuthClientInformationFullSchema2 ); if (DEBUG) debugLog("Client info result:", clientInfo ? "Found" : "Not found"); return clientInfo; } /** * Saves client information * @param clientInformation The client information to save */ async saveClientInformation(clientInformation) { if (DEBUG) debugLog("Saving client info", { client_id: clientInformation.client_id }); await writeJsonFile(this.serverUrlHash, "client_info.json", clientInformation); } /** * Gets the OAuth tokens if they exist * @returns The OAuth tokens or undefined */ async tokens() { if (DEBUG) { debugLog("Reading OAuth tokens"); debugLog("Token request stack trace:", new Error().stack); } const tokens = await readJsonFile(this.serverUrlHash, "tokens.json", OAuthTokensSchema); if (DEBUG) { if (tokens) { const timeLeft = tokens.expires_in || 0; if (typeof tokens.expires_in !== "number" || tokens.expires_in < 0) { debugLog("\u26A0\uFE0F WARNING: Invalid expires_in detected while reading tokens \u26A0\uFE0F", { expiresIn: tokens.expires_in, tokenObject: JSON.stringify(tokens), stack: new Error("Invalid expires_in value").stack }); } debugLog("Token result:", { found: true, hasAccessToken: !!tokens.access_token, hasRefreshToken: !!tokens.refresh_token, expiresIn: `${timeLeft} seconds`, isExpired: timeLeft <= 0, expiresInValue: tokens.expires_in }); } else { debugLog("Token result: Not found"); } } return tokens; } /** * Saves OAuth tokens * @param tokens The tokens to save */ async saveTokens(tokens) { if (DEBUG) { const timeLeft = tokens.expires_in || 0; if (typeof tokens.expires_in !== "number" || tokens.expires_in < 0) { debugLog("\u26A0\uFE0F WARNING: Invalid expires_in detected in tokens \u26A0\uFE0F", { expiresIn: tokens.expires_in, tokenObject: JSON.stringify(tokens), stack: new Error("Invalid expires_in value").stack }); } debugLog("Saving tokens", { hasAccessToken: !!tokens.access_token, hasRefreshToken: !!tokens.refresh_token, expiresIn: `${timeLeft} seconds`, expiresInValue: tokens.expires_in }); } await writeJsonFile(this.serverUrlHash, "tokens.json", tokens); } /** * Redirects the user to the authorization URL * @param authorizationUrl The URL to redirect to */ async redirectToAuthorization(authorizationUrl) { log(` Please authorize this client by visiting: ${authorizationUrl.toString()} `); if (DEBUG) debugLog("Redirecting to authorization URL", authorizationUrl.toString()); try { await open(authorizationUrl.toString()); log("Browser opened automatically."); } catch (error) { log("Could not open browser automatically. Please copy and paste the URL above into your browser."); if (DEBUG) debugLog("Failed to open browser", error); } } /** * Saves the PKCE code verifier * @param codeVerifier The code verifier to save */ async saveCodeVerifier(codeVerifier) { if (DEBUG) debugLog("Saving code verifier"); await writeTextFile(this.serverUrlHash, "code_verifier.txt", codeVerifier); } /** * Gets the PKCE code verifier * @returns The code verifier */ async codeVerifier() { if (DEBUG) debugLog("Reading code verifier"); const verifier = await readTextFile(this.serverUrlHash, "code_verifier.txt", "No code verifier saved for session"); if (DEBUG) debugLog("Code verifier found:", !!verifier); return verifier; } /** * Invalidates the specified credentials * @param scope The scope of credentials to invalidate */ async invalidateCredentials(scope) { if (DEBUG) debugLog(`Invalidating credentials: ${scope}`); switch (scope) { case "all": await Promise.all([ deleteConfigFile(this.serverUrlHash, "client_info.json"), deleteConfigFile(this.serverUrlHash, "tokens.json"), deleteConfigFile(this.serverUrlHash, "code_verifier.txt") ]); if (DEBUG) debugLog("All credentials invalidated"); break; case "client": await deleteConfigFile(this.serverUrlHash, "client_info.json"); if (DEBUG) debugLog("Client information invalidated"); break; case "tokens": await deleteConfigFile(this.serverUrlHash, "tokens.json"); if (DEBUG) debugLog("OAuth tokens invalidated"); break; case "verifier": await deleteConfigFile(this.serverUrlHash, "code_verifier.txt"); if (DEBUG) debugLog("Code verifier invalidated"); break; default: throw new Error(`Unknown credential scope: ${scope}`); } } }; // src/lib/coordination.ts import express2 from "express"; import { unlinkSync } from "fs"; async function isPidRunning(pid2) { try { process.kill(pid2, 0); if (DEBUG) debugLog(`Process ${pid2} is running`); return true; } catch (err) { if (DEBUG) debugLog(`Process ${pid2} is not running`, err); return false; } } async function isLockValid(lockData) { if (DEBUG) debugLog("Checking if lockfile is valid", lockData); const MAX_LOCK_AGE = 30 * 60 * 1e3; if (Date.now() - lockData.timestamp > MAX_LOCK_AGE) { log("Lockfile is too old"); if (DEBUG) debugLog("Lockfile is too old", { age: Date.now() - lockData.timestamp, maxAge: MAX_LOCK_AGE }); return false; } if (!await isPidRunning(lockData.pid)) { log("Process from lockfile is not running"); if (DEBUG) debugLog("Process from lockfile is not running", { pid: lockData.pid }); return false; } try { if (DEBUG) debugLog("Checking if endpoint is accessible", { port: lockData.port }); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1e3); const response = await fetch(`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`, { signal: controller.signal }); clearTimeout(timeout); const isValid = response.status === 200 || response.status === 202; if (DEBUG) debugLog(`Endpoint check result: ${isValid ? "valid" : "invalid"}`, { status: response.status }); return isValid; } catch (error) { log(`Error connecting to auth server: ${error.message}`); if (DEBUG) debugLog("Error connecting to auth server", error); return false; } } async function waitForAuthentication(port) { log(`Waiting for authentication from the server on port ${port}...`); try { let attempts = 0; while (true) { attempts++; const url = `http://127.0.0.1:${port}/wait-for-auth`; log(`Querying: ${url}`); if (DEBUG) debugLog(`Poll attempt ${attempts}`); try { const response = await fetch(url); if (DEBUG) debugLog(`Poll response status: ${response.status}`); if (response.status === 200) { log(`Authentication completed by other instance`); return true; } else if (response.status === 202) { log(`Authentication still in progress`); if (DEBUG) debugLog(`Will retry in 1s`); await new Promise((resolve) => setTimeout(resolve, 1e3)); } else { log(`Unexpected response status: ${response.status}`); return false; } } catch (fetchError) { if (DEBUG) debugLog(`Fetch error during poll`, fetchError); await new Promise((resolve) => setTimeout(resolve, 2e3)); } } } catch (error) { log(`Error waiting for authentication: ${error.message}`); if (DEBUG) debugLog(`Error waiting for authentication`, error); return false; } } function createLazyAuthCoordinator(serverUrlHash, callbackPort, events) { let authState = null; return { initializeAuth: async () => { if (authState) { if (DEBUG) debugLog("Auth already initialized, reusing existing state"); return authState; } log("Initializing auth coordination on-demand"); if (DEBUG) debugLog("Initializing auth coordination on-demand", { serverUrlHash, callbackPort }); authState = await coordinateAuth(serverUrlHash, callbackPort, events); if (DEBUG) debugLog("Auth coordination completed", { skipBrowserAuth: authState.skipBrowserAuth }); return authState; } }; } async function coordinateAuth(serverUrlHash, callbackPort, events) { if (DEBUG) debugLog("Coordinating authentication", { serverUrlHash, callbackPort }); const lockData = process.platform === "win32" ? null : await checkLockfile(serverUrlHash); if (DEBUG) { if (process.platform === "win32") { debugLog("Skipping lockfile check on Windows"); } else { debugLog("Lockfile check result", { found: !!lockData, lockData }); } } if (lockData && await isLockValid(lockData)) { log(`Another instance is handling authentication on port ${lockData.port} (pid: ${lockData.pid})`); try { if (DEBUG) debugLog("Waiting for authentication from other instance"); const authCompleted = await waitForAuthentication(lockData.port); if (authCompleted) { log("Authentication completed by another instance. Using tokens from disk"); const dummyServer = express2().listen(0); const dummyPort = dummyServer.address().port; if (DEBUG) debugLog("Started dummy server", { port: dummyPort }); const dummyWaitForAuthCode = () => { log("WARNING: waitForAuthCode called in secondary instance - this is unexpected"); return new Promise(() => { }); }; return { server: dummyServer, waitForAuthCode: dummyWaitForAuthCode, skipBrowserAuth: true }; } else { log("Taking over authentication process..."); } } catch (error) { log(`Error waiting for authentication: ${error}`); if (DEBUG) debugLog("Error waiting for authentication", error); } if (DEBUG) debugLog("Other instance did not complete auth successfully, deleting lockfile"); await deleteLockfile(serverUrlHash); } else if (lockData) { log("Found invalid lockfile, deleting it"); await deleteLockfile(serverUrlHash); } if (DEBUG) debugLog("Setting up OAuth callback server", { port: callbackPort }); const { server, waitForAuthCode, authCompletedPromise } = setupOAuthCallbackServerWithLongPoll({ port: callbackPort, path: "/oauth/callback", events }); const address = server.address(); const actualPort = address.port; if (DEBUG) debugLog("OAuth callback server running", { port: actualPort }); log(`Creating lockfile for server ${serverUrlHash} with process ${process.pid} on port ${actualPort}`); await createLockfile(serverUrlHash, process.pid, actualPort); const cleanupHandler = async () => { try { log(`Cleaning up lockfile for server ${serverUrlHash}`); await deleteLockfile(serverUrlHash); } catch (error) { log(`Error cleaning up lockfile: ${error}`); if (DEBUG) debugLog("Error cleaning up lockfile", error); } }; process.once("exit", () => { try { const configPath = getConfigFilePath(serverUrlHash, "lock.json"); unlinkSync(configPath); if (DEBUG) console.error(`[DEBUG] Removed lockfile on exit: ${configPath}`); } catch (error) { if (DEBUG) console.error(`[DEBUG] Error removing lockfile on exit:`, error); } }); process.once("SIGINT", async () => { if (DEBUG) debugLog("Received SIGINT signal, cleaning up"); await cleanupHandler(); }); if (DEBUG) debugLog("Auth coordination complete, returning primary instance handlers"); return { server, waitForAuthCode, skipBrowserAuth: false }; } export { version, log, mcpProxy, connectToRemoteServer, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash, NodeOAuthClientProvider, createLazyAuthCoordinator };