Add .denoignore file and update implementation plan with additional tasks for type safety, dependency management, testing, documentation, and build configuration.

This commit is contained in:
Minoru Mizutani 2025-04-29 03:42:59 +09:00
parent 00b1d15cfd
commit 4394c0773d
No known key found for this signature in database
10 changed files with 1815 additions and 1096 deletions

1
.denoignore Normal file
View file

@ -0,0 +1 @@
*.md

View file

@ -30,4 +30,21 @@ Here is a plan to transform your Node.js CLI package into a Deno CLI project, fo
- [x] Run the main task using `deno task proxy:start <args...>`. - [x] Run the main task using `deno task proxy:start <args...>`.
- [x] Thoroughly test the CLI's functionality to ensure it behaves identically to the original Node.js version. Pay close attention to areas involving file system access, network requests, environment variables, and process management. - [x] Thoroughly test the CLI's functionality to ensure it behaves identically to the original Node.js version. Pay close attention to areas involving file system access, network requests, environment variables, and process management.
6. **Refine Type Safety & Linting:**
- [x] Address remaining `any` types and other linter warnings identified by `deno lint`.
- [x] Improve type definitions, especially for external library interactions (e.g., express request/response types if kept).
- [x] Run `deno fmt` to ensure consistent code formatting.
7. **Improve Dependency Management:**
- [ ] Evaluate replacing `npm:express` with a native Deno HTTP server solution (e.g., `Deno.serve` or from `std/http`).
- [ ] Evaluate replacing `npm:open` with a Deno equivalent or platform-specific commands.
8. **Implement Testing:**
- [ ] Add unit tests for key utility functions (e.g., in `utils.ts`, `mcp-auth-config.ts`).
- [ ] Add integration tests for the core proxy (`proxy.ts`) and client (`client.ts`) functionality.
9. **Enhance Documentation:**
- [ ] Update `README.md` with Deno-specific installation, usage, and contribution guidelines.
- [ ] Add comprehensive TSDoc comments to all exported functions, classes, and interfaces.
10. **Build & Distribution:**
- [ ] Configure `deno publish` settings if intending to publish to deno.land/x.
- [ ] Explore using `deno compile` to create standalone executables for different platforms.
This plan prioritizes modifying the existing TypeScript code minimally while adapting the project structure and configuration for Deno. We will start by modifying `deno.json` and `src/proxy.ts`. This plan prioritizes modifying the existing TypeScript code minimally while adapting the project structure and configuration for Deno. We will start by modifying `deno.json` and `src/proxy.ts`.

1793
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -10,157 +10,198 @@
* If callback-port is not specified, an available port will be automatically selected. * If callback-port is not specified, an available port will be automatically selected.
*/ */
import { EventEmitter } from 'node:events' import { EventEmitter } from "node:events";
import { Client } from 'npm:@modelcontextprotocol/sdk/client/index.js' import { Client } from "npm:@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from 'npm:@modelcontextprotocol/sdk/client/sse.js' import { SSEClientTransport } from "npm:@modelcontextprotocol/sdk/client/sse.js";
import { ListResourcesResultSchema, ListToolsResultSchema } from 'npm:@modelcontextprotocol/sdk/types.js' import {
import { UnauthorizedError } from 'npm:@modelcontextprotocol/sdk/client/auth.js' ListResourcesResultSchema,
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider.ts' ListToolsResultSchema,
import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, getServerUrlHash } from './lib/utils.ts' } from "npm:@modelcontextprotocol/sdk/types.js";
import { coordinateAuth } from './lib/coordination.ts' import { UnauthorizedError } from "npm:@modelcontextprotocol/sdk/client/auth.js";
import { NodeOAuthClientProvider } from "./lib/node-oauth-client-provider.ts";
import {
getServerUrlHash,
log,
MCP_REMOTE_VERSION,
parseCommandLineArgs,
setupSignalHandlers,
} from "./lib/utils.ts";
import { coordinateAuth } from "./lib/coordination.ts";
/** /**
* Main function to run the client * Main function to run the client
*/ */
async function runClient(serverUrl: string, callbackPort: number, headers: Record<string, string>) { async function runClient(
serverUrl: string,
callbackPort: number,
headers: Record<string, string>,
) {
// Set up event emitter for auth flow // Set up event emitter for auth flow
const events = new EventEmitter() const events = new EventEmitter();
// Get the server URL hash for lockfile operations // Get the server URL hash for lockfile operations
const serverUrlHash = getServerUrlHash(serverUrl) const serverUrlHash = getServerUrlHash(serverUrl);
// Coordinate authentication with other instances // Coordinate authentication with other instances
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events) const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(
serverUrlHash,
callbackPort,
events,
);
// Create the OAuth client provider // Create the OAuth client provider
const authProvider = new NodeOAuthClientProvider({ const authProvider = new NodeOAuthClientProvider({
serverUrl, serverUrl,
callbackPort, callbackPort,
clientName: 'MCP CLI Client', clientName: "MCP CLI Client",
}) });
// If auth was completed by another instance, just log that we'll use the auth from disk // If auth was completed by another instance, just log that we'll use the auth from disk
if (skipBrowserAuth) { if (skipBrowserAuth) {
log('Authentication was completed by another instance - will use tokens from disk...') log(
"Authentication was completed by another instance - will use tokens from disk...",
);
// TODO: remove, the callback is happening before the tokens are exchanged // TODO: remove, the callback is happening before the tokens are exchanged
// so we're slightly too early // so we're slightly too early
await new Promise((res) => setTimeout(res, 1_000)) await new Promise((res) => setTimeout(res, 1_000));
} }
// Create the client // Create the client
const client = new Client( const client = new Client(
{ {
name: 'mcp-remote', name: "mcp-remote",
version: MCP_REMOTE_VERSION, version: MCP_REMOTE_VERSION,
}, },
{ {
capabilities: {}, capabilities: {},
}, },
) );
// Create the transport factory // Create the transport factory
const url = new URL(serverUrl) const url = new URL(serverUrl);
function initTransport() { function initTransport() {
const transport = new SSEClientTransport(url, { authProvider, requestInit: { headers } }) const transport = new SSEClientTransport(url, {
authProvider,
requestInit: { headers },
});
// Set up message and error handlers // Set up message and error handlers
transport.onmessage = (message) => { transport.onmessage = (message) => {
log('Received message:', JSON.stringify(message, null, 2)) log("Received message:", JSON.stringify(message, null, 2));
} };
transport.onerror = (error) => { transport.onerror = (error) => {
log('Transport error:', error) log("Transport error:", error);
} };
transport.onclose = () => { transport.onclose = () => {
log('Connection closed.') log("Connection closed.");
Deno.exit(0) Deno.exit(0);
} };
return transport return transport;
} }
const transport = initTransport() const transport = initTransport();
// Set up cleanup handler // Set up cleanup handler
const cleanup = async () => { const cleanup = async () => {
log('\nClosing connection...') log("\nClosing connection...");
await client.close() await client.close();
server.close() server.close();
} };
setupSignalHandlers(cleanup) setupSignalHandlers(cleanup);
// Try to connect // Try to connect
try { try {
log('Connecting to server...') log("Connecting to server...");
await client.connect(transport) await client.connect(transport);
log('Connected successfully!') log("Connected successfully!");
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { if (
log('Authentication required. Waiting for authorization...') error instanceof UnauthorizedError ||
(error instanceof Error && error.message.includes("Unauthorized"))
) {
log("Authentication required. Waiting for authorization...");
// Wait for the authorization code from the callback or another instance // Wait for the authorization code from the callback or another instance
const code = await waitForAuthCode() const code = await waitForAuthCode();
try { try {
log('Completing authorization...') log("Completing authorization...");
await transport.finishAuth(code) await transport.finishAuth(code);
// Reconnect after authorization with a new transport // Reconnect after authorization with a new transport
log('Connecting after authorization...') log("Connecting after authorization...");
await client.connect(initTransport()) await client.connect(initTransport());
log('Connected successfully!') log("Connected successfully!");
// Request tools list after auth // Request tools list after auth
log('Requesting tools list...') log("Requesting tools list...");
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) const tools = await client.request(
log('Tools:', JSON.stringify(tools, null, 2)) { method: "tools/list" },
ListToolsResultSchema,
);
log("Tools:", JSON.stringify(tools, null, 2));
// Request resources list after auth // Request resources list after auth
log('Requesting resource list...') log("Requesting resource list...");
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) const resources = await client.request(
log('Resources:', JSON.stringify(resources, null, 2)) { method: "resources/list" },
ListResourcesResultSchema,
);
log("Resources:", JSON.stringify(resources, null, 2));
log('Listening for messages. Press Ctrl+C to exit.') log("Listening for messages. Press Ctrl+C to exit.");
} catch (authError) { } catch (authError) {
log('Authorization error:', authError) log("Authorization error:", authError);
server.close() server.close();
Deno.exit(1) Deno.exit(1);
} }
} else { } else {
log('Connection error:', error) log("Connection error:", error);
server.close() server.close();
Deno.exit(1) Deno.exit(1);
} }
} }
try { try {
// Request tools list // Request tools list
log('Requesting tools list...') log("Requesting tools list...");
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) const tools = await client.request(
log('Tools:', JSON.stringify(tools, null, 2)) { method: "tools/list" },
ListToolsResultSchema,
);
log("Tools:", JSON.stringify(tools, null, 2));
} catch (e) { } catch (e) {
log('Error requesting tools list:', e) log("Error requesting tools list:", e);
} }
try { try {
// Request resources list // Request resources list
log('Requesting resource list...') log("Requesting resource list...");
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) const resources = await client.request(
log('Resources:', JSON.stringify(resources, null, 2)) { method: "resources/list" },
ListResourcesResultSchema,
);
log("Resources:", JSON.stringify(resources, null, 2));
} catch (e) { } catch (e) {
log('Error requesting resources list:', e) log("Error requesting resources list:", e);
} }
log('Listening for messages. Press Ctrl+C to exit.') log("Listening for messages. Press Ctrl+C to exit.");
} }
// Parse command-line arguments and run the client // Parse command-line arguments and run the client
parseCommandLineArgs(Deno.args, 3333, 'Usage: deno run src/client.ts <https://server-url> [callback-port]') parseCommandLineArgs(
Deno.args,
3333,
"Usage: deno run src/client.ts <https://server-url> [callback-port]",
)
.then(({ serverUrl, callbackPort, headers }) => { .then(({ serverUrl, callbackPort, headers }) => {
return runClient(serverUrl, callbackPort, headers) return runClient(serverUrl, callbackPort, headers);
}) })
.catch((error) => { .catch((error) => {
console.error('Fatal error:', error) console.error("Fatal error:", error);
Deno.exit(1) Deno.exit(1);
}) });

View file

@ -1,9 +1,15 @@
import { checkLockfile, createLockfile, deleteLockfile, getConfigFilePath, type LockfileData } from './mcp-auth-config.ts' import {
import type { EventEmitter } from 'node:events' checkLockfile,
import type { Server } from 'node:http' createLockfile,
import express from 'npm:express' deleteLockfile,
import type { AddressInfo } from 'node:net' getConfigFilePath,
import { log, setupOAuthCallbackServerWithLongPoll } from './utils.ts' type LockfileData,
} from "./mcp-auth-config.ts";
import type { EventEmitter } from "node:events";
import type { Server } from "node:http";
import express from "npm:express";
import type { AddressInfo } from "node:net";
import { log, setupOAuthCallbackServerWithLongPoll } from "./utils.ts";
/** /**
* Checks if a process with the given PID is running * Checks if a process with the given PID is running
@ -14,13 +20,13 @@ export async function isPidRunning(pid: number): Promise<boolean> {
try { try {
// Deno doesn't have a direct equivalent to process.kill(pid, 0) // Deno doesn't have a direct equivalent to process.kill(pid, 0)
// On non-Windows platforms, we can try to use kill system call to check // On non-Windows platforms, we can try to use kill system call to check
if (Deno.build.os !== 'windows') { if (Deno.build.os !== "windows") {
try { try {
// Using Deno.run to check if process exists // Using Deno.run to check if process exists
const command = new Deno.Command('kill', { const command = new Deno.Command("kill", {
args: ['-0', pid.toString()], args: ["-0", pid.toString()],
stdout: 'null', stdout: "null",
stderr: 'null', stderr: "null",
}); });
const { success } = await command.output(); const { success } = await command.output();
return success; return success;
@ -30,9 +36,9 @@ export async function isPidRunning(pid: number): Promise<boolean> {
} else { } else {
// On Windows, use tasklist to check if process exists // On Windows, use tasklist to check if process exists
try { try {
const command = new Deno.Command('tasklist', { const command = new Deno.Command("tasklist", {
args: ['/FI', `PID eq ${pid}`, '/NH'], args: ["/FI", `PID eq ${pid}`, "/NH"],
stdout: 'piped', stdout: "piped",
}); });
const { stdout } = await command.output(); const { stdout } = await command.output();
const output = new TextDecoder().decode(stdout); const output = new TextDecoder().decode(stdout);
@ -53,32 +59,35 @@ export async function isPidRunning(pid: number): Promise<boolean> {
*/ */
export async function isLockValid(lockData: LockfileData): Promise<boolean> { export async function isLockValid(lockData: LockfileData): Promise<boolean> {
// Check if the lockfile is too old (over 30 minutes) // Check if the lockfile is too old (over 30 minutes)
const MAX_LOCK_AGE = 30 * 60 * 1000 // 30 minutes const MAX_LOCK_AGE = 30 * 60 * 1000; // 30 minutes
if (Date.now() - lockData.timestamp > MAX_LOCK_AGE) { if (Date.now() - lockData.timestamp > MAX_LOCK_AGE) {
log('Lockfile is too old') log("Lockfile is too old");
return false return false;
} }
// Check if the process is still running // Check if the process is still running
if (!(await isPidRunning(lockData.pid))) { if (!(await isPidRunning(lockData.pid))) {
log('Process from lockfile is not running') log("Process from lockfile is not running");
return false return false;
} }
// Check if the endpoint is accessible // Check if the endpoint is accessible
try { try {
const controller = new AbortController() const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1000) const timeout = setTimeout(() => controller.abort(), 1000);
const response = await fetch(`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`, { const response = await fetch(
signal: controller.signal, `http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`,
}) {
signal: controller.signal,
},
);
clearTimeout(timeout) clearTimeout(timeout);
return response.status === 200 || response.status === 202 return response.status === 200 || response.status === 202;
} catch (error) { } catch (error) {
log(`Error connecting to auth server: ${(error as Error).message}`) log(`Error connecting to auth server: ${(error as Error).message}`);
return false return false;
} }
} }
@ -88,31 +97,31 @@ export async function isLockValid(lockData: LockfileData): Promise<boolean> {
* @returns True if authentication completed successfully, false otherwise * @returns True if authentication completed successfully, false otherwise
*/ */
export async function waitForAuthentication(port: number): Promise<boolean> { export async function waitForAuthentication(port: number): Promise<boolean> {
log(`Waiting for authentication from the server on port ${port}...`) log(`Waiting for authentication from the server on port ${port}...`);
try { try {
while (true) { while (true) {
const url = `http://127.0.0.1:${port}/wait-for-auth` const url = `http://127.0.0.1:${port}/wait-for-auth`;
log(`Querying: ${url}`) log(`Querying: ${url}`);
const response = await fetch(url) const response = await fetch(url);
if (response.status === 200) { if (response.status === 200) {
// Auth completed, but we don't return the code anymore // Auth completed, but we don't return the code anymore
log('Authentication completed by other instance') log("Authentication completed by other instance");
return true return true;
} }
if (response.status === 202) { if (response.status === 202) {
// Continue polling // Continue polling
log('Authentication still in progress') log("Authentication still in progress");
await new Promise((resolve) => setTimeout(resolve, 1000)) await new Promise((resolve) => setTimeout(resolve, 1000));
} else { } else {
log(`Unexpected response status: ${response.status}`) log(`Unexpected response status: ${response.status}`);
return false return false;
} }
} }
} catch (error) { } catch (error) {
log(`Error waiting for authentication: ${(error as Error).message}`) log(`Error waiting for authentication: ${(error as Error).message}`);
return false return false;
} }
} }
@ -127,72 +136,85 @@ export async function coordinateAuth(
serverUrlHash: string, serverUrlHash: string,
callbackPort: number, callbackPort: number,
events: EventEmitter, events: EventEmitter,
): Promise<{ server: Server; waitForAuthCode: () => Promise<string>; skipBrowserAuth: boolean }> { ): Promise<
{
server: Server;
waitForAuthCode: () => Promise<string>;
skipBrowserAuth: boolean;
}
> {
// Check for a lockfile (disabled on Windows for the time being) // Check for a lockfile (disabled on Windows for the time being)
const lockData = Deno.build.os === 'windows' ? null : await checkLockfile(serverUrlHash) const lockData = Deno.build.os === "windows"
? null
: await checkLockfile(serverUrlHash);
// If there's a valid lockfile, try to use the existing auth process // If there's a valid lockfile, try to use the existing auth process
if (lockData && (await isLockValid(lockData))) { if (lockData && (await isLockValid(lockData))) {
log(`Another instance is handling authentication on port ${lockData.port}`) log(`Another instance is handling authentication on port ${lockData.port}`);
try { try {
// Try to wait for the authentication to complete // Try to wait for the authentication to complete
const authCompleted = await waitForAuthentication(lockData.port) const authCompleted = await waitForAuthentication(lockData.port);
if (authCompleted) { if (authCompleted) {
log('Authentication completed by another instance') log("Authentication completed by another instance");
// Setup a dummy server - the client will use tokens directly from disk // Setup a dummy server - the client will use tokens directly from disk
const dummyServer = express().listen(0) // Listen on any available port const dummyServer = express().listen(0); // Listen on any available port
// This shouldn't actually be called in normal operation, but provide it for API compatibility // This shouldn't actually be called in normal operation, but provide it for API compatibility
const dummyWaitForAuthCode = () => { const dummyWaitForAuthCode = () => {
log('WARNING: waitForAuthCode called in secondary instance - this is unexpected') log(
"WARNING: waitForAuthCode called in secondary instance - this is unexpected",
);
// Return a promise that never resolves - the client should use the tokens from disk instead // Return a promise that never resolves - the client should use the tokens from disk instead
return new Promise<string>(() => {}) return new Promise<string>(() => {});
} };
return { return {
server: dummyServer, server: dummyServer,
waitForAuthCode: dummyWaitForAuthCode, waitForAuthCode: dummyWaitForAuthCode,
skipBrowserAuth: true, skipBrowserAuth: true,
} };
} }
log('Taking over authentication process...') log("Taking over authentication process...");
} catch (error) { } catch (error) {
log(`Error waiting for authentication: ${error}`) log(`Error waiting for authentication: ${error}`);
} }
// If we get here, the other process didn't complete auth successfully // If we get here, the other process didn't complete auth successfully
await deleteLockfile(serverUrlHash) await deleteLockfile(serverUrlHash);
} else if (lockData) { } else if (lockData) {
// Invalid lockfile, delete its // Invalid lockfile, delete its
log('Found invalid lockfile, deleting it') log("Found invalid lockfile, deleting it");
await deleteLockfile(serverUrlHash) await deleteLockfile(serverUrlHash);
} }
// Create our own lockfile // Create our own lockfile
const { server, waitForAuthCode, authCompletedPromise: _ } = setupOAuthCallbackServerWithLongPoll({ const { server, waitForAuthCode, authCompletedPromise: _ } =
port: callbackPort, setupOAuthCallbackServerWithLongPoll({
path: '/oauth/callback', port: callbackPort,
events, path: "/oauth/callback",
}) events,
});
// Get the actual port the server is running on // Get the actual port the server is running on
const address = server.address() as AddressInfo const address = server.address() as AddressInfo;
const actualPort = address.port const actualPort = address.port;
log(`Creating lockfile for server ${serverUrlHash} with process ${Deno.pid} on port ${actualPort}`) log(
await createLockfile(serverUrlHash, Deno.pid, actualPort) `Creating lockfile for server ${serverUrlHash} with process ${Deno.pid} on port ${actualPort}`,
);
await createLockfile(serverUrlHash, Deno.pid, actualPort);
// Make sure lockfile is deleted on process exit // Make sure lockfile is deleted on process exit
const cleanupHandler = async () => { const cleanupHandler = async () => {
try { try {
log(`Cleaning up lockfile for server ${serverUrlHash}`) log(`Cleaning up lockfile for server ${serverUrlHash}`);
await deleteLockfile(serverUrlHash) await deleteLockfile(serverUrlHash);
} catch (error) { } catch (error) {
log(`Error cleaning up lockfile: ${error}`) log(`Error cleaning up lockfile: ${error}`);
} }
} };
// Setup exit handlers for Deno // Setup exit handlers for Deno
// Note: Deno doesn't have process.once but we can use addEventListener // Note: Deno doesn't have process.once but we can use addEventListener
@ -200,7 +222,7 @@ export async function coordinateAuth(
addEventListener("unload", () => { addEventListener("unload", () => {
try { try {
// Synchronous cleanup // Synchronous cleanup
const configPath = getConfigFilePath(serverUrlHash, 'lock.json') const configPath = getConfigFilePath(serverUrlHash, "lock.json");
// Use Deno's synchronous file API // Use Deno's synchronous file API
try { try {
Deno.removeSync(configPath); Deno.removeSync(configPath);
@ -222,5 +244,5 @@ export async function coordinateAuth(
server, server,
waitForAuthCode, waitForAuthCode,
skipBrowserAuth: false, skipBrowserAuth: false,
} };
} }

View file

@ -1,6 +1,6 @@
import path from 'node:path' import path from "node:path";
import os from 'node:os' import os from "node:os";
import { log, MCP_REMOTE_VERSION } from './utils.ts' import { log, MCP_REMOTE_VERSION } from "./utils.ts";
/** /**
* MCP Remote Authentication Configuration * MCP Remote Authentication Configuration
@ -26,9 +26,9 @@ import { log, MCP_REMOTE_VERSION } from './utils.ts'
* Lockfile data structure * Lockfile data structure
*/ */
export interface LockfileData { export interface LockfileData {
pid: number pid: number;
port: number port: number;
timestamp: number timestamp: number;
} }
/** /**
@ -37,13 +37,17 @@ export interface LockfileData {
* @param pid The process ID * @param pid The process ID
* @param port The port the server is running on * @param port The port the server is running on
*/ */
export async function createLockfile(serverUrlHash: string, pid: number, port: number): Promise<void> { export async function createLockfile(
serverUrlHash: string,
pid: number,
port: number,
): Promise<void> {
const lockData: LockfileData = { const lockData: LockfileData = {
pid, pid,
port, port,
timestamp: Date.now(), timestamp: Date.now(),
} };
await writeJsonFile(serverUrlHash, 'lock.json', lockData) await writeJsonFile(serverUrlHash, "lock.json", lockData);
} }
/** /**
@ -51,20 +55,30 @@ export async function createLockfile(serverUrlHash: string, pid: number, port: n
* @param serverUrlHash The hash of the server URL * @param serverUrlHash The hash of the server URL
* @returns The lockfile data or null if it doesn't exist * @returns The lockfile data or null if it doesn't exist
*/ */
export async function checkLockfile(serverUrlHash: string): Promise<LockfileData | null> { export async function checkLockfile(
serverUrlHash: string,
): Promise<LockfileData | null> {
try { try {
const lockfile = await readJsonFile<LockfileData>(serverUrlHash, 'lock.json', { const lockfile = await readJsonFile<LockfileData>(
async parseAsync(data: any) { serverUrlHash,
if (typeof data !== 'object' || data === null) return null "lock.json",
if (typeof data.pid !== 'number' || typeof data.port !== 'number' || typeof data.timestamp !== 'number') { {
return null parseAsync(data: unknown) {
} if (typeof data !== "object" || data === null) return null;
return data as LockfileData if (
typeof (data as LockfileData).pid !== "number" ||
typeof (data as LockfileData).port !== "number" ||
typeof (data as LockfileData).timestamp !== "number"
) {
return null;
}
return data as LockfileData;
},
}, },
}) );
return lockfile || null return lockfile || null;
} catch { } catch {
return null return null;
} }
} }
@ -73,7 +87,7 @@ export async function checkLockfile(serverUrlHash: string): Promise<LockfileData
* @param serverUrlHash The hash of the server URL * @param serverUrlHash The hash of the server URL
*/ */
export async function deleteLockfile(serverUrlHash: string): Promise<void> { export async function deleteLockfile(serverUrlHash: string): Promise<void> {
await deleteConfigFile(serverUrlHash, 'lock.json') await deleteConfigFile(serverUrlHash, "lock.json");
} }
/** /**
@ -81,9 +95,10 @@ export async function deleteLockfile(serverUrlHash: string): Promise<void> {
* @returns The path to the configuration directory * @returns The path to the configuration directory
*/ */
export function getConfigDir(): string { export function getConfigDir(): string {
const baseConfigDir = Deno.env.get('MCP_REMOTE_CONFIG_DIR') || path.join(os.homedir(), '.mcp-auth') const baseConfigDir = Deno.env.get("MCP_REMOTE_CONFIG_DIR") ||
path.join(os.homedir(), ".mcp-auth");
// Add a version subdirectory so we don't need to worry about backwards/forwards compatibility yet // Add a version subdirectory so we don't need to worry about backwards/forwards compatibility yet
return path.join(baseConfigDir, `mcp-remote-${MCP_REMOTE_VERSION}`) return path.join(baseConfigDir, `mcp-remote-${MCP_REMOTE_VERSION}`);
} }
/** /**
@ -91,11 +106,11 @@ export function getConfigDir(): string {
*/ */
export async function ensureConfigDir(): Promise<void> { export async function ensureConfigDir(): Promise<void> {
try { try {
const configDir = getConfigDir() const configDir = getConfigDir();
await Deno.mkdir(configDir, { recursive: true }) await Deno.mkdir(configDir, { recursive: true });
} catch (error) { } catch (error) {
log('Error creating config directory:', error) log("Error creating config directory:", error);
throw error throw error;
} }
} }
@ -105,9 +120,12 @@ export async function ensureConfigDir(): Promise<void> {
* @param filename The name of the file * @param filename The name of the file
* @returns The absolute file path * @returns The absolute file path
*/ */
export function getConfigFilePath(serverUrlHash: string, filename: string): string { export function getConfigFilePath(
const configDir = getConfigDir() serverUrlHash: string,
return path.join(configDir, `${serverUrlHash}_${filename}`) filename: string,
): string {
const configDir = getConfigDir();
return path.join(configDir, `${serverUrlHash}_${filename}`);
} }
/** /**
@ -115,14 +133,17 @@ export function getConfigFilePath(serverUrlHash: string, filename: string): stri
* @param serverUrlHash The hash of the server URL * @param serverUrlHash The hash of the server URL
* @param filename The name of the file to delete * @param filename The name of the file to delete
*/ */
export async function deleteConfigFile(serverUrlHash: string, filename: string): Promise<void> { export async function deleteConfigFile(
serverUrlHash: string,
filename: string,
): Promise<void> {
try { try {
const filePath = getConfigFilePath(serverUrlHash, filename) const filePath = getConfigFilePath(serverUrlHash, filename);
await Deno.remove(filePath) await Deno.remove(filePath);
} catch (error) { } catch (_error) {
// Ignore if file doesn't exist // Ignore if file doesn't exist
if ((error as Deno.errors.NotFound).name !== 'NotFound') { if ((_error as Deno.errors.NotFound).name !== "NotFound") {
log(`Error deleting ${filename}:`, error) log(`Error deleting ${filename}:`, _error);
} }
} }
} }
@ -134,22 +155,25 @@ export async function deleteConfigFile(serverUrlHash: string, filename: string):
* @param schema The schema to validate against * @param schema The schema to validate against
* @returns The parsed file content or undefined if the file doesn't exist * @returns The parsed file content or undefined if the file doesn't exist
*/ */
export async function readJsonFile<T>(serverUrlHash: string, filename: string, schema: any): Promise<T | undefined> { export async function readJsonFile<T>(
serverUrlHash: string,
filename: string,
schema: { parseAsync: (data: unknown) => Promise<T | null> | T | null },
): Promise<T | undefined> {
try { try {
await ensureConfigDir() await ensureConfigDir();
const filePath = getConfigFilePath(serverUrlHash, filename) const filePath = getConfigFilePath(serverUrlHash, filename);
const content = await Deno.readTextFile(filePath) const content = await Deno.readTextFile(filePath);
const result = await schema.parseAsync(JSON.parse(content)) const result = await schema.parseAsync(JSON.parse(content));
// console.log({ filename: result }) return result ?? undefined;
return result } catch (_error) {
} catch (error) { if (_error instanceof Deno.errors.NotFound) {
if (error instanceof Deno.errors.NotFound) {
// console.log(`File ${filename} does not exist`) // console.log(`File ${filename} does not exist`)
return undefined return undefined;
} }
log(`Error reading ${filename}:`, error) log(`Error reading ${filename}:`, _error);
return undefined return undefined;
} }
} }
@ -159,14 +183,18 @@ export async function readJsonFile<T>(serverUrlHash: string, filename: string, s
* @param filename The name of the file to write * @param filename The name of the file to write
* @param data The data to write * @param data The data to write
*/ */
export async function writeJsonFile(serverUrlHash: string, filename: string, data: any): Promise<void> { export async function writeJsonFile(
serverUrlHash: string,
filename: string,
data: unknown,
): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
const filePath = getConfigFilePath(serverUrlHash, filename) const filePath = getConfigFilePath(serverUrlHash, filename);
await Deno.writeTextFile(filePath, JSON.stringify(data, null, 2)) await Deno.writeTextFile(filePath, JSON.stringify(data, null, 2));
} catch (error) { } catch (_error) {
log(`Error writing ${filename}:`, error) log(`Error writing ${filename}:`, _error);
throw error throw _error;
} }
} }
@ -177,13 +205,17 @@ export async function writeJsonFile(serverUrlHash: string, filename: string, dat
* @param errorMessage Optional custom error message * @param errorMessage Optional custom error message
* @returns The file content as a string * @returns The file content as a string
*/ */
export async function readTextFile(serverUrlHash: string, filename: string, errorMessage?: string): Promise<string> { export async function readTextFile(
serverUrlHash: string,
filename: string,
errorMessage?: string,
): Promise<string> {
try { try {
await ensureConfigDir() await ensureConfigDir();
const filePath = getConfigFilePath(serverUrlHash, filename) const filePath = getConfigFilePath(serverUrlHash, filename);
return await Deno.readTextFile(filePath) return await Deno.readTextFile(filePath);
} catch (error) { } catch (_error) {
throw new Error(errorMessage || `Error reading ${filename}`) throw new Error(errorMessage || `Error reading ${filename}`);
} }
} }
@ -193,13 +225,17 @@ export async function readTextFile(serverUrlHash: string, filename: string, erro
* @param filename The name of the file to write * @param filename The name of the file to write
* @param text The text to write * @param text The text to write
*/ */
export async function writeTextFile(serverUrlHash: string, filename: string, text: string): Promise<void> { export async function writeTextFile(
serverUrlHash: string,
filename: string,
text: string,
): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
const filePath = getConfigFilePath(serverUrlHash, filename) const filePath = getConfigFilePath(serverUrlHash, filename);
await Deno.writeTextFile(filePath, text) await Deno.writeTextFile(filePath, text);
} catch (error) { } catch (error) {
log(`Error writing ${filename}:`, error) log(`Error writing ${filename}:`, error);
throw error throw error;
} }
} }

View file

@ -1,84 +1,108 @@
import open from 'npm:open' import open from "npm:open";
import { OAuthClientProvider } from 'npm:@modelcontextprotocol/sdk/client/auth.js' import type { OAuthClientProvider } from "npm:@modelcontextprotocol/sdk/client/auth.js";
import "npm:@modelcontextprotocol/sdk/client/auth.js";
import { import {
OAuthClientInformationSchema,
OAuthTokensSchema,
} from "npm:@modelcontextprotocol/sdk/shared/auth.js";
import type {
OAuthClientInformation, OAuthClientInformation,
OAuthClientInformationFull, OAuthClientInformationFull,
OAuthClientInformationSchema,
OAuthTokens, OAuthTokens,
OAuthTokensSchema, } from "npm:@modelcontextprotocol/sdk/shared/auth.js";
} from 'npm:@modelcontextprotocol/sdk/shared/auth.js' import type { OAuthProviderOptions } from "./types.ts";
import type { OAuthProviderOptions } from './types.ts' import {
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile } from './mcp-auth-config.ts' readJsonFile,
import { getServerUrlHash, log, MCP_REMOTE_VERSION } from './utils.ts' readTextFile,
writeJsonFile,
writeTextFile,
} from "./mcp-auth-config.ts";
import { getServerUrlHash, log, MCP_REMOTE_VERSION } from "./utils.ts";
/** /**
* Implements the OAuthClientProvider interface for Node.js environments. * Implements the OAuthClientProvider interface for Node.js environments.
* Handles OAuth flow and token storage for MCP clients. * Handles OAuth flow and token storage for MCP clients.
*/ */
export class NodeOAuthClientProvider implements OAuthClientProvider { export class NodeOAuthClientProvider implements OAuthClientProvider {
private serverUrlHash: string private serverUrlHash: string;
private callbackPath: string private callbackPath: string;
private clientName: string private clientName: string;
private clientUri: string private clientUri: string;
private softwareId: string private softwareId: string;
private softwareVersion: string private softwareVersion: string;
/** /**
* Creates a new NodeOAuthClientProvider * Creates a new NodeOAuthClientProvider
* @param options Configuration options for the provider * @param options Configuration options for the provider
*/ */
constructor(readonly options: OAuthProviderOptions) { constructor(readonly options: OAuthProviderOptions) {
this.serverUrlHash = getServerUrlHash(options.serverUrl) this.serverUrlHash = getServerUrlHash(options.serverUrl);
this.callbackPath = options.callbackPath || '/oauth/callback' this.callbackPath = options.callbackPath || "/oauth/callback";
this.clientName = options.clientName || 'MCP CLI Client' this.clientName = options.clientName || "MCP CLI Client";
this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli' this.clientUri = options.clientUri ||
this.softwareId = options.softwareId || '2e6dc280-f3c3-4e01-99a7-8181dbd1d23d' "https://github.com/modelcontextprotocol/mcp-cli";
this.softwareVersion = options.softwareVersion || MCP_REMOTE_VERSION this.softwareId = options.softwareId ||
"2e6dc280-f3c3-4e01-99a7-8181dbd1d23d";
this.softwareVersion = options.softwareVersion || MCP_REMOTE_VERSION;
} }
get redirectUrl(): string { get redirectUrl(): string {
return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}` return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}`;
} }
get clientMetadata() { get clientMetadata() {
return { return {
redirect_uris: [this.redirectUrl], redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: 'none', token_endpoint_auth_method: "none",
grant_types: ['authorization_code', 'refresh_token'], grant_types: ["authorization_code", "refresh_token"],
response_types: ['code'], response_types: ["code"],
client_name: this.clientName, client_name: this.clientName,
client_uri: this.clientUri, client_uri: this.clientUri,
software_id: this.softwareId, software_id: this.softwareId,
software_version: this.softwareVersion, software_version: this.softwareVersion,
} };
} }
/** /**
* Gets the client information if it exists * Gets the client information if it exists
* @returns The client information or undefined * @returns The client information or undefined
*/ */
async clientInformation(): Promise<OAuthClientInformation | undefined> { clientInformation(): Promise<OAuthClientInformation | undefined> {
// log('Reading client info') // log('Reading client info')
return readJsonFile<OAuthClientInformation>(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema) return readJsonFile<OAuthClientInformation>(
this.serverUrlHash,
"client_info.json",
OAuthClientInformationSchema,
);
} }
/** /**
* Saves client information * Saves client information
* @param clientInformation The client information to save * @param clientInformation The client information to save
*/ */
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> { async saveClientInformation(
clientInformation: OAuthClientInformationFull,
): Promise<void> {
// log('Saving client info') // log('Saving client info')
await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation) await writeJsonFile(
this.serverUrlHash,
"client_info.json",
clientInformation,
);
} }
/** /**
* Gets the OAuth tokens if they exist * Gets the OAuth tokens if they exist
* @returns The OAuth tokens or undefined * @returns The OAuth tokens or undefined
*/ */
async tokens(): Promise<OAuthTokens | undefined> { tokens(): Promise<OAuthTokens | undefined> {
// log('Reading tokens') // log('Reading tokens')
// console.log(new Error().stack) // console.log(new Error().stack)
return readJsonFile<OAuthTokens>(this.serverUrlHash, 'tokens.json', OAuthTokensSchema) return readJsonFile<OAuthTokens>(
this.serverUrlHash,
"tokens.json",
OAuthTokensSchema,
);
} }
/** /**
@ -87,7 +111,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
*/ */
async saveTokens(tokens: OAuthTokens): Promise<void> { async saveTokens(tokens: OAuthTokens): Promise<void> {
// log('Saving tokens') // log('Saving tokens')
await writeJsonFile(this.serverUrlHash, 'tokens.json', tokens) await writeJsonFile(this.serverUrlHash, "tokens.json", tokens);
} }
/** /**
@ -95,12 +119,16 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
* @param authorizationUrl The URL to redirect to * @param authorizationUrl The URL to redirect to
*/ */
async redirectToAuthorization(authorizationUrl: URL): Promise<void> { async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
log(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`) log(
`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`,
);
try { try {
await open(authorizationUrl.toString()) await open(authorizationUrl.toString());
log('Browser opened automatically.') log("Browser opened automatically.");
} catch (error) { } catch (_error) {
log('Could not open browser automatically. Please copy and paste the URL above into your browser.') log(
"Could not open browser automatically. Please copy and paste the URL above into your browser.",
);
} }
} }
@ -110,7 +138,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
*/ */
async saveCodeVerifier(codeVerifier: string): Promise<void> { async saveCodeVerifier(codeVerifier: string): Promise<void> {
// log('Saving code verifier') // log('Saving code verifier')
await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier) await writeTextFile(this.serverUrlHash, "code_verifier.txt", codeVerifier);
} }
/** /**
@ -119,6 +147,10 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
*/ */
async codeVerifier(): Promise<string> { async codeVerifier(): Promise<string> {
// log('Reading code verifier') // log('Reading code verifier')
return await readTextFile(this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session') return await readTextFile(
this.serverUrlHash,
"code_verifier.txt",
"No code verifier saved for session",
);
} }
} }

View file

@ -1,25 +1,25 @@
import type { EventEmitter } from 'node:events' import type { EventEmitter } from "node:events";
/** /**
* Options for creating an OAuth client provider * Options for creating an OAuth client provider
*/ */
export interface OAuthProviderOptions { export interface OAuthProviderOptions {
/** Server URL to connect to */ /** Server URL to connect to */
serverUrl: string serverUrl: string;
/** Port for the OAuth callback server */ /** Port for the OAuth callback server */
callbackPort: number callbackPort: number;
/** Path for the OAuth callback endpoint */ /** Path for the OAuth callback endpoint */
callbackPath?: string callbackPath?: string;
/** Directory to store OAuth credentials */ /** Directory to store OAuth credentials */
configDir?: string configDir?: string;
/** Client name to use for OAuth registration */ /** Client name to use for OAuth registration */
clientName?: string clientName?: string;
/** Client URI to use for OAuth registration */ /** Client URI to use for OAuth registration */
clientUri?: string clientUri?: string;
/** Software ID to use for OAuth registration */ /** Software ID to use for OAuth registration */
softwareId?: string softwareId?: string;
/** Software version to use for OAuth registration */ /** Software version to use for OAuth registration */
softwareVersion?: string softwareVersion?: string;
} }
/** /**
@ -27,9 +27,9 @@ export interface OAuthProviderOptions {
*/ */
export interface OAuthCallbackServerOptions { export interface OAuthCallbackServerOptions {
/** Port for the callback server */ /** Port for the callback server */
port: number port: number;
/** Path for the callback endpoint */ /** Path for the callback endpoint */
path: string path: string;
/** Event emitter to signal when auth code is received */ /** Event emitter to signal when auth code is received */
events: EventEmitter events: EventEmitter;
} }

View file

@ -1,66 +1,74 @@
import { type OAuthClientProvider, UnauthorizedError } from 'npm:@modelcontextprotocol/sdk/client/auth.js' import {
import { SSEClientTransport } from 'npm:@modelcontextprotocol/sdk/client/sse.js' type OAuthClientProvider,
import type { Transport } from 'npm:@modelcontextprotocol/sdk/shared/transport.js' UnauthorizedError,
import type { OAuthCallbackServerOptions } from './types.ts' } from "npm:@modelcontextprotocol/sdk/client/auth.js";
import express from 'npm:express' import { SSEClientTransport } from "npm:@modelcontextprotocol/sdk/client/sse.js";
import net from 'node:net' import type { Transport } from "npm:@modelcontextprotocol/sdk/shared/transport.js";
import crypto from 'node:crypto' import type { OAuthCallbackServerOptions } from "./types.ts";
import express from "npm:express";
import net from "node:net";
import crypto from "node:crypto";
// Package version from deno.json (set a constant for now) // Package version from deno.json (set a constant for now)
export const MCP_REMOTE_VERSION = '1.0.0' // TODO: Find better way to get version in Deno export const MCP_REMOTE_VERSION = "1.0.0"; // TODO: Find better way to get version in Deno
const pid = Deno.pid const pid = Deno.pid;
export function log(str: string, ...rest: unknown[]) { export function log(str: string, ...rest: unknown[]) {
// Using stderr so that it doesn't interfere with stdout // Using stderr so that it doesn't interfere with stdout
console.error(`[${pid}] ${str}`, ...rest) console.error(`[${pid}] ${str}`, ...rest);
} }
/** /**
* Creates a bidirectional proxy between two transports * Creates a bidirectional proxy between two transports
* @param params The transport connections to proxy between * @param params The transport connections to proxy between
*/ */
export function mcpProxy({ transportToClient, transportToServer }: { transportToClient: Transport; transportToServer: Transport }) { export function mcpProxy(
let transportToClientClosed = false { transportToClient, transportToServer }: {
let transportToServerClosed = false transportToClient: Transport;
transportToServer: Transport;
},
) {
let transportToClientClosed = false;
let transportToServerClosed = false;
transportToClient.onmessage = (message) => { transportToClient.onmessage = (message) => {
// @ts-expect-error TODO // @ts-expect-error TODO
log('[Local→Remote]', message.method || message.id) log("[Local→Remote]", message.method || message.id);
transportToServer.send(message).catch(onServerError) transportToServer.send(message).catch(onServerError);
} };
transportToServer.onmessage = (message) => { transportToServer.onmessage = (message) => {
// @ts-expect-error TODO: fix this type // @ts-expect-error TODO: fix this type
log('[Remote→Local]', message.method || message.id) log("[Remote→Local]", message.method || message.id);
transportToClient.send(message).catch(onClientError) transportToClient.send(message).catch(onClientError);
} };
transportToClient.onclose = () => { transportToClient.onclose = () => {
if (transportToServerClosed) { if (transportToServerClosed) {
return return;
} }
transportToClientClosed = true transportToClientClosed = true;
transportToServer.close().catch(onServerError) transportToServer.close().catch(onServerError);
} };
transportToServer.onclose = () => { transportToServer.onclose = () => {
if (transportToClientClosed) { if (transportToClientClosed) {
return return;
} }
transportToServerClosed = true transportToServerClosed = true;
transportToClient.close().catch(onClientError) transportToClient.close().catch(onClientError);
} };
transportToClient.onerror = onClientError transportToClient.onerror = onClientError;
transportToServer.onerror = onServerError transportToServer.onerror = onServerError;
function onClientError(error: Error) { function onClientError(error: Error) {
log('Error from local client:', error) log("Error from local client:", error);
} }
function onServerError(error: Error) { function onServerError(error: Error) {
log('Error from remote server:', error) log("Error from remote server:", error);
} }
} }
@ -80,8 +88,8 @@ export async function connectToRemoteServer(
waitForAuthCode: () => Promise<string>, waitForAuthCode: () => Promise<string>,
skipBrowserAuth = false, skipBrowserAuth = false,
): Promise<SSEClientTransport> { ): Promise<SSEClientTransport> {
log(`[${pid}] Connecting to remote server: ${serverUrl}`) log(`[${pid}] Connecting to remote server: ${serverUrl}`);
const url = new URL(serverUrl) const url = new URL(serverUrl);
// Create transport with eventSourceInit to pass Authorization header if present // Create transport with eventSourceInit to pass Authorization header if present
const eventSourceInit = { const eventSourceInit = {
@ -92,7 +100,9 @@ export async function connectToRemoteServer(
headers: { headers: {
...(init?.headers as Record<string, string> | undefined), ...(init?.headers as Record<string, string> | undefined),
...headers, ...headers,
...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}), ...(tokens?.access_token
? { Authorization: `Bearer ${tokens.access_token}` }
: {}),
Accept: "text/event-stream", Accept: "text/event-stream",
} as Record<string, string>, } as Record<string, string>,
}) })
@ -104,39 +114,47 @@ export async function connectToRemoteServer(
authProvider, authProvider,
requestInit: { headers }, requestInit: { headers },
eventSourceInit, eventSourceInit,
}) });
try { try {
await transport.start() await transport.start();
log('Connected to remote server') log("Connected to remote server");
return transport return transport;
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { if (
error instanceof UnauthorizedError ||
(error instanceof Error && error.message.includes("Unauthorized"))
) {
if (skipBrowserAuth) { if (skipBrowserAuth) {
log('Authentication required but skipping browser auth - using shared auth') log(
"Authentication required but skipping browser auth - using shared auth",
);
} else { } else {
log('Authentication required. Waiting for authorization...') log("Authentication required. Waiting for authorization...");
} }
// Wait for the authorization code from the callback // Wait for the authorization code from the callback
const code = await waitForAuthCode() const code = await waitForAuthCode();
try { try {
log('Completing authorization...') log("Completing authorization...");
await transport.finishAuth(code) await transport.finishAuth(code);
// Create a new transport after auth // Create a new transport after auth
const newTransport = new SSEClientTransport(url, { authProvider, requestInit: { headers } }) const newTransport = new SSEClientTransport(url, {
await newTransport.start() authProvider,
log('Connected to remote server after authentication') requestInit: { headers },
return newTransport });
await newTransport.start();
log("Connected to remote server after authentication");
return newTransport;
} catch (authError) { } catch (authError) {
log('Authorization error:', authError) log("Authorization error:", authError);
throw authError throw authError;
} }
} else { } else {
log('Connection error:', error) log("Connection error:", error);
throw error throw error;
} }
} }
} }
@ -146,92 +164,96 @@ export async function connectToRemoteServer(
* @param options The server options * @param options The server options
* @returns An object with the server, authCode, and waitForAuthCode function * @returns An object with the server, authCode, and waitForAuthCode function
*/ */
export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServerOptions) { export function setupOAuthCallbackServerWithLongPoll(
let authCode: string | null = null options: OAuthCallbackServerOptions,
const app = express() ) {
let authCode: string | null = null;
const app = express();
// Create a promise to track when auth is completed // Create a promise to track when auth is completed
let authCompletedResolve: (code: string) => void let authCompletedResolve: (code: string) => void;
const authCompletedPromise = new Promise<string>((resolve) => { const authCompletedPromise = new Promise<string>((resolve) => {
authCompletedResolve = resolve authCompletedResolve = resolve;
}) });
// Long-polling endpoint // Long-polling endpoint
app.get('/wait-for-auth', (req, res) => { app.get("/wait-for-auth", (req, res) => {
if (authCode) { if (authCode) {
// Auth already completed - just return 200 without the actual code // Auth already completed - just return 200 without the actual code
// Secondary instances will read tokens from disk // Secondary instances will read tokens from disk
log('Auth already completed, returning 200') log("Auth already completed, returning 200");
res.status(200).send('Authentication completed') res.status(200).send("Authentication completed");
return return;
} }
if (req.query.poll === 'false') { if (req.query.poll === "false") {
log('Client requested no long poll, responding with 202') log("Client requested no long poll, responding with 202");
res.status(202).send('Authentication in progress') res.status(202).send("Authentication in progress");
return return;
} }
// Long poll - wait for up to 30 seconds // Long poll - wait for up to 30 seconds
const longPollTimeout = setTimeout(() => { const longPollTimeout = setTimeout(() => {
log('Long poll timeout reached, responding with 202') log("Long poll timeout reached, responding with 202");
res.status(202).send('Authentication in progress') res.status(202).send("Authentication in progress");
}, 30000) }, 30000);
// If auth completes while we're waiting, send the response immediately // If auth completes while we're waiting, send the response immediately
authCompletedPromise authCompletedPromise
.then(() => { .then(() => {
clearTimeout(longPollTimeout) clearTimeout(longPollTimeout);
if (!res.headersSent) { if (!res.headersSent) {
log('Auth completed during long poll, responding with 200') log("Auth completed during long poll, responding with 200");
res.status(200).send('Authentication completed') res.status(200).send("Authentication completed");
} }
}) })
.catch(() => { .catch(() => {
clearTimeout(longPollTimeout) clearTimeout(longPollTimeout);
if (!res.headersSent) { if (!res.headersSent) {
log('Auth failed during long poll, responding with 500') log("Auth failed during long poll, responding with 500");
res.status(500).send('Authentication failed') res.status(500).send("Authentication failed");
} }
}) });
}) });
// OAuth callback endpoint // OAuth callback endpoint
app.get(options.path, (req, res) => { app.get(options.path, (req, res) => {
const code = req.query.code as string | undefined const code = req.query.code as string | undefined;
if (!code) { if (!code) {
res.status(400).send('Error: No authorization code received') res.status(400).send("Error: No authorization code received");
return return;
} }
authCode = code authCode = code;
log('Auth code received, resolving promise') log("Auth code received, resolving promise");
authCompletedResolve(code) authCompletedResolve(code);
res.send('Authorization successful! You may close this window and return to the CLI.') res.send(
"Authorization successful! You may close this window and return to the CLI.",
);
// Notify main flow that auth code is available // Notify main flow that auth code is available
options.events.emit('auth-code-received', code) options.events.emit("auth-code-received", code);
}) });
const server = app.listen(options.port, () => { const server = app.listen(options.port, () => {
log(`OAuth callback server running at http://127.0.0.1:${options.port}`) log(`OAuth callback server running at http://127.0.0.1:${options.port}`);
}) });
const waitForAuthCode = (): Promise<string> => { const waitForAuthCode = (): Promise<string> => {
return new Promise((resolve) => { return new Promise((resolve) => {
if (authCode) { if (authCode) {
resolve(authCode) resolve(authCode);
return return;
} }
options.events.once('auth-code-received', (code) => { options.events.once("auth-code-received", (code) => {
resolve(code) resolve(code);
}) });
}) });
} };
return { server, authCode, waitForAuthCode, authCompletedPromise } return { server, authCode, waitForAuthCode, authCompletedPromise };
} }
/** /**
@ -240,8 +262,9 @@ export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServe
* @returns An object with the server, authCode, and waitForAuthCode function * @returns An object with the server, authCode, and waitForAuthCode function
*/ */
export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
const { server, authCode, waitForAuthCode } = setupOAuthCallbackServerWithLongPoll(options) const { server, authCode, waitForAuthCode } =
return { server, authCode, waitForAuthCode } setupOAuthCallbackServerWithLongPoll(options);
return { server, authCode, waitForAuthCode };
} }
/** /**
@ -249,29 +272,31 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
* @param preferredPort Optional preferred port to try first * @param preferredPort Optional preferred port to try first
* @returns A promise that resolves to an available port number * @returns A promise that resolves to an available port number
*/ */
export async function findAvailablePort(preferredPort?: number): Promise<number> { export function findAvailablePort(
preferredPort?: number,
): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server = net.createServer() const server = net.createServer();
server.on('error', (err: NodeJS.ErrnoException) => { server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') { if (err.code === "EADDRINUSE") {
// If preferred port is in use, get a random port // If preferred port is in use, get a random port
server.listen(0) server.listen(0);
} else { } else {
reject(err) reject(err);
} }
}) });
server.on('listening', () => { server.on("listening", () => {
const { port } = server.address() as net.AddressInfo const { port } = server.address() as net.AddressInfo;
server.close(() => { server.close(() => {
resolve(port) resolve(port);
}) });
}) });
// Try preferred port first, or get a random port // Try preferred port first, or get a random port
server.listen(preferredPort || 0) server.listen(preferredPort || 0);
}) });
} }
/** /**
@ -281,75 +306,85 @@ export async function findAvailablePort(preferredPort?: number): Promise<number>
* @param usage Usage message to show on error * @param usage Usage message to show on error
* @returns A promise that resolves to an object with parsed serverUrl, callbackPort and headers * @returns A promise that resolves to an object with parsed serverUrl, callbackPort and headers
*/ */
export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) { export async function parseCommandLineArgs(
args: string[],
defaultPort: number,
usage: string,
) {
// Check for help flag // Check for help flag
if (args.includes('--help') || args.includes('-h')) { if (args.includes("--help") || args.includes("-h")) {
log(usage) log(usage);
Deno.exit(0) Deno.exit(0);
} }
// Process headers // Process headers
const headers: Record<string, string> = {} const headers: Record<string, string> = {};
args.forEach((arg, i) => { args.forEach((arg, i) => {
if (arg === '--header' && i < args.length - 1) { if (arg === "--header" && i < args.length - 1) {
const value = args[i + 1] const value = args[i + 1];
const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/) const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/);
if (match) { if (match) {
headers[match[1]] = match[2] headers[match[1]] = match[2];
} else { } else {
log(`Warning: ignoring invalid header argument: ${value}`) log(`Warning: ignoring invalid header argument: ${value}`);
} }
args.splice(i, 2) args.splice(i, 2);
} }
}) });
const serverUrl = args[0] const serverUrl = args[0];
const specifiedPort = args[1] ? Number.parseInt(args[1], 10) : undefined const specifiedPort = args[1] ? Number.parseInt(args[1], 10) : undefined;
const allowHttp = args.includes('--allow-http') const allowHttp = args.includes("--allow-http");
if (!serverUrl) { if (!serverUrl) {
log(usage) log(usage);
Deno.exit(1) Deno.exit(1);
} }
const url = new URL(serverUrl) const url = new URL(serverUrl);
const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:' const isLocalhost =
(url.hostname === "localhost" || url.hostname === "127.0.0.1") &&
url.protocol === "http:";
if (!(url.protocol === 'https:' || isLocalhost || allowHttp)) { if (!(url.protocol === "https:" || isLocalhost || allowHttp)) {
log('Error: Non-HTTPS URLs are only allowed for localhost or when --allow-http flag is provided') log(
log(usage) "Error: Non-HTTPS URLs are only allowed for localhost or when --allow-http flag is provided",
Deno.exit(1) );
log(usage);
Deno.exit(1);
} }
// Use the specified port, or find an available one // Use the specified port, or find an available one
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort)) const callbackPort = specifiedPort || (await findAvailablePort(defaultPort));
if (specifiedPort) { if (specifiedPort) {
log(`Using specified callback port: ${callbackPort}`) log(`Using specified callback port: ${callbackPort}`);
} else { } else {
log(`Using automatically selected callback port: ${callbackPort}`) log(`Using automatically selected callback port: ${callbackPort}`);
} }
if (Object.keys(headers).length > 0) { if (Object.keys(headers).length > 0) {
log(`Using custom headers: ${JSON.stringify(headers)}`) log(`Using custom headers: ${JSON.stringify(headers)}`);
} }
// Replace environment variables in headers // Replace environment variables in headers
// example `Authorization: Bearer ${TOKEN}` will read process.env.TOKEN // example `Authorization: Bearer ${TOKEN}` will read process.env.TOKEN
for (const [key, value] of Object.entries(headers)) { for (const [key, value] of Object.entries(headers)) {
headers[key] = value.replace(/\$\{([^}]+)}/g, (match, envVarName) => { headers[key] = value.replace(/\$\{([^}]+)}/g, (match, envVarName) => {
const envVarValue = Deno.env.get(envVarName) const envVarValue = Deno.env.get(envVarName);
if (envVarValue !== undefined) { if (envVarValue !== undefined) {
log(`Replacing ${match} with environment value in header '${key}'`) log(`Replacing ${match} with environment value in header '${key}'`);
return envVarValue return envVarValue;
} }
log(`Warning: Environment variable '${envVarName}' not found for header '${key}'.`) log(
return '' `Warning: Environment variable '${envVarName}' not found for header '${key}'.`,
}) );
return "";
});
} }
return { serverUrl, callbackPort, headers } return { serverUrl, callbackPort, headers };
} }
/** /**
@ -358,21 +393,21 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
*/ */
export function setupSignalHandlers(cleanup: () => Promise<void>) { export function setupSignalHandlers(cleanup: () => Promise<void>) {
Deno.addSignalListener("SIGINT", async () => { Deno.addSignalListener("SIGINT", async () => {
log('\nShutting down...') log("\nShutting down...");
await cleanup() await cleanup();
Deno.exit(0) Deno.exit(0);
}) });
// For SIGTERM // For SIGTERM
try { try {
Deno.addSignalListener("SIGTERM", async () => { Deno.addSignalListener("SIGTERM", async () => {
log('\nReceived SIGTERM. Shutting down...') log("\nReceived SIGTERM. Shutting down...");
await cleanup() await cleanup();
Deno.exit(0) Deno.exit(0);
}) });
} catch (e) { } catch (_e) {
// SIGTERM might not be available on all platforms // SIGTERM might not be available on all platforms
log('SIGTERM handler not available on this platform') log("SIGTERM handler not available on this platform");
} }
} }
@ -382,5 +417,5 @@ export function setupSignalHandlers(cleanup: () => Promise<void>) {
* @returns The hashed server URL * @returns The hashed server URL
*/ */
export function getServerUrlHash(serverUrl: string): string { export function getServerUrlHash(serverUrl: string): string {
return crypto.createHash('md5').update(serverUrl).digest('hex') return crypto.createHash("md5").update(serverUrl).digest("hex");
} }

View file

@ -10,69 +10,95 @@
* If callback-port is not specified, an available port will be automatically selected. * If callback-port is not specified, an available port will be automatically selected.
*/ */
import { EventEmitter } from 'node:events' import { EventEmitter } from "node:events";
import { StdioServerTransport } from 'npm:@modelcontextprotocol/sdk/server/stdio.js' import { StdioServerTransport } from "npm:@modelcontextprotocol/sdk/server/stdio.js";
import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash } from './lib/utils.ts' import {
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider.ts' connectToRemoteServer,
import { coordinateAuth } from './lib/coordination.ts' getServerUrlHash,
log,
mcpProxy,
parseCommandLineArgs,
setupSignalHandlers,
} from "./lib/utils.ts";
import { NodeOAuthClientProvider } from "./lib/node-oauth-client-provider.ts";
import { coordinateAuth } from "./lib/coordination.ts";
/** /**
* Main function to run the proxy * Main function to run the proxy
*/ */
async function runProxy(serverUrl: string, callbackPort: number, headers: Record<string, string>) { async function runProxy(
serverUrl: string,
callbackPort: number,
headers: Record<string, string>,
) {
// Set up event emitter for auth flow // Set up event emitter for auth flow
const events = new EventEmitter() const events = new EventEmitter();
// Get the server URL hash for lockfile operations // Get the server URL hash for lockfile operations
const serverUrlHash = getServerUrlHash(serverUrl) const serverUrlHash = getServerUrlHash(serverUrl);
// Coordinate authentication with other instances // Coordinate authentication with other instances
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events) const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(
serverUrlHash,
callbackPort,
events,
);
// Create the OAuth client provider // Create the OAuth client provider
const authProvider = new NodeOAuthClientProvider({ const authProvider = new NodeOAuthClientProvider({
serverUrl, serverUrl,
callbackPort, callbackPort,
clientName: 'MCP CLI Proxy', clientName: "MCP CLI Proxy",
}) });
// If auth was completed by another instance, just log that we'll use the auth from disk // If auth was completed by another instance, just log that we'll use the auth from disk
if (skipBrowserAuth) { if (skipBrowserAuth) {
log('Authentication was completed by another instance - will use tokens from disk') log(
"Authentication was completed by another instance - will use tokens from disk",
);
// TODO: remove, the callback is happening before the tokens are exchanged // TODO: remove, the callback is happening before the tokens are exchanged
// so we're slightly too early // so we're slightly too early
await new Promise((res) => setTimeout(res, 1_000)) await new Promise((res) => setTimeout(res, 1_000));
} }
// Create the STDIO transport for local connections // Create the STDIO transport for local connections
const localTransport = new StdioServerTransport() const localTransport = new StdioServerTransport();
try { try {
// Connect to remote server with authentication // Connect to remote server with authentication
const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, headers, waitForAuthCode, skipBrowserAuth) const remoteTransport = await connectToRemoteServer(
serverUrl,
authProvider,
headers,
waitForAuthCode,
skipBrowserAuth,
);
// Set up bidirectional proxy between local and remote transports // Set up bidirectional proxy between local and remote transports
mcpProxy({ mcpProxy({
transportToClient: localTransport, transportToClient: localTransport,
transportToServer: remoteTransport, transportToServer: remoteTransport,
}) });
// Start the local STDIO server // Start the local STDIO server
await localTransport.start() await localTransport.start();
log('Local STDIO server running') log("Local STDIO server running");
log('Proxy established successfully between local STDIO and remote SSE') log("Proxy established successfully between local STDIO and remote SSE");
log('Press Ctrl+C to exit') log("Press Ctrl+C to exit");
// Setup cleanup handler // Setup cleanup handler
const cleanup = async () => { const cleanup = async () => {
await remoteTransport.close() await remoteTransport.close();
await localTransport.close() await localTransport.close();
server.close() server.close();
} };
setupSignalHandlers(cleanup) setupSignalHandlers(cleanup);
} catch (error) { } catch (error) {
log('Fatal error:', error) log("Fatal error:", error);
if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) { if (
error instanceof Error &&
error.message.includes("self-signed certificate in certificate chain")
) {
log(`You may be behind a VPN! log(`You may be behind a VPN!
If you are behind a VPN, you can try setting the NODE_EXTRA_CA_CERTS environment variable to point If you are behind a VPN, you can try setting the NODE_EXTRA_CA_CERTS environment variable to point
@ -92,19 +118,23 @@ to the CA certificate file. If using claude_desktop_config.json, this might look
} }
} }
} }
`) `);
} }
server.close() server.close();
Deno.exit(1) Deno.exit(1);
} }
} }
// Parse command-line arguments and run the proxy // Parse command-line arguments and run the proxy
parseCommandLineArgs(Deno.args, 3334, 'Usage: deno run src/proxy.ts <https://server-url> [callback-port]') parseCommandLineArgs(
Deno.args,
3334,
"Usage: deno run src/proxy.ts <https://server-url> [callback-port]",
)
.then(({ serverUrl, callbackPort, headers }) => { .then(({ serverUrl, callbackPort, headers }) => {
return runProxy(serverUrl, callbackPort, headers) return runProxy(serverUrl, callbackPort, headers);
}) })
.catch((error) => { .catch((error) => {
log('Fatal error:', error) log("Fatal error:", error);
Deno.exit(1) Deno.exit(1);
}) });