mcp-walichat
Version:
WaliChat WhatsApp MCP connector
976 lines (969 loc) • 36.1 kB
JavaScript
// 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
};