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] 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`.

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.
*/
import { EventEmitter } from 'node:events'
import { Client } from 'npm:@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from 'npm:@modelcontextprotocol/sdk/client/sse.js'
import { ListResourcesResultSchema, ListToolsResultSchema } from 'npm:@modelcontextprotocol/sdk/types.js'
import { UnauthorizedError } from 'npm:@modelcontextprotocol/sdk/client/auth.js'
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider.ts'
import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, getServerUrlHash } from './lib/utils.ts'
import { coordinateAuth } from './lib/coordination.ts'
import { EventEmitter } from "node:events";
import { Client } from "npm:@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "npm:@modelcontextprotocol/sdk/client/sse.js";
import {
ListResourcesResultSchema,
ListToolsResultSchema,
} from "npm:@modelcontextprotocol/sdk/types.js";
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
*/
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
const events = new EventEmitter()
const events = new EventEmitter();
// Get the server URL hash for lockfile operations
const serverUrlHash = getServerUrlHash(serverUrl)
const serverUrlHash = getServerUrlHash(serverUrl);
// 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
const authProvider = new NodeOAuthClientProvider({
serverUrl,
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 (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
// 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
const client = new Client(
{
name: 'mcp-remote',
name: "mcp-remote",
version: MCP_REMOTE_VERSION,
},
{
capabilities: {},
},
)
);
// Create the transport factory
const url = new URL(serverUrl)
const url = new URL(serverUrl);
function initTransport() {
const transport = new SSEClientTransport(url, { authProvider, requestInit: { headers } })
const transport = new SSEClientTransport(url, {
authProvider,
requestInit: { headers },
});
// Set up message and error handlers
transport.onmessage = (message) => {
log('Received message:', JSON.stringify(message, null, 2))
}
log("Received message:", JSON.stringify(message, null, 2));
};
transport.onerror = (error) => {
log('Transport error:', error)
}
log("Transport error:", error);
};
transport.onclose = () => {
log('Connection closed.')
Deno.exit(0)
}
return transport
log("Connection closed.");
Deno.exit(0);
};
return transport;
}
const transport = initTransport()
const transport = initTransport();
// Set up cleanup handler
const cleanup = async () => {
log('\nClosing connection...')
await client.close()
server.close()
}
setupSignalHandlers(cleanup)
log("\nClosing connection...");
await client.close();
server.close();
};
setupSignalHandlers(cleanup);
// Try to connect
try {
log('Connecting to server...')
await client.connect(transport)
log('Connected successfully!')
log("Connecting to server...");
await client.connect(transport);
log("Connected successfully!");
} catch (error) {
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
log('Authentication required. Waiting for authorization...')
if (
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
const code = await waitForAuthCode()
const code = await waitForAuthCode();
try {
log('Completing authorization...')
await transport.finishAuth(code)
log("Completing authorization...");
await transport.finishAuth(code);
// Reconnect after authorization with a new transport
log('Connecting after authorization...')
await client.connect(initTransport())
log("Connecting after authorization...");
await client.connect(initTransport());
log('Connected successfully!')
log("Connected successfully!");
// Request tools list after auth
log('Requesting tools list...')
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
log('Tools:', JSON.stringify(tools, null, 2))
log("Requesting tools list...");
const tools = await client.request(
{ method: "tools/list" },
ListToolsResultSchema,
);
log("Tools:", JSON.stringify(tools, null, 2));
// Request resources list after auth
log('Requesting resource list...')
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
log('Resources:', JSON.stringify(resources, null, 2))
log("Requesting resource list...");
const resources = await client.request(
{ 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) {
log('Authorization error:', authError)
server.close()
Deno.exit(1)
log("Authorization error:", authError);
server.close();
Deno.exit(1);
}
} else {
log('Connection error:', error)
server.close()
Deno.exit(1)
log("Connection error:", error);
server.close();
Deno.exit(1);
}
}
try {
// Request tools list
log('Requesting tools list...')
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
log('Tools:', JSON.stringify(tools, null, 2))
log("Requesting tools list...");
const tools = await client.request(
{ method: "tools/list" },
ListToolsResultSchema,
);
log("Tools:", JSON.stringify(tools, null, 2));
} catch (e) {
log('Error requesting tools list:', e)
log("Error requesting tools list:", e);
}
try {
// Request resources list
log('Requesting resource list...')
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
log('Resources:', JSON.stringify(resources, null, 2))
log("Requesting resource list...");
const resources = await client.request(
{ method: "resources/list" },
ListResourcesResultSchema,
);
log("Resources:", JSON.stringify(resources, null, 2));
} 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
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 }) => {
return runClient(serverUrl, callbackPort, headers)
return runClient(serverUrl, callbackPort, headers);
})
.catch((error) => {
console.error('Fatal error:', error)
Deno.exit(1)
})
console.error("Fatal error:", error);
Deno.exit(1);
});

View file

@ -1,9 +1,15 @@
import { checkLockfile, createLockfile, deleteLockfile, getConfigFilePath, 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'
import {
checkLockfile,
createLockfile,
deleteLockfile,
getConfigFilePath,
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
@ -14,13 +20,13 @@ export async function isPidRunning(pid: number): Promise<boolean> {
try {
// 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
if (Deno.build.os !== 'windows') {
if (Deno.build.os !== "windows") {
try {
// Using Deno.run to check if process exists
const command = new Deno.Command('kill', {
args: ['-0', pid.toString()],
stdout: 'null',
stderr: 'null',
const command = new Deno.Command("kill", {
args: ["-0", pid.toString()],
stdout: "null",
stderr: "null",
});
const { success } = await command.output();
return success;
@ -30,9 +36,9 @@ export async function isPidRunning(pid: number): Promise<boolean> {
} else {
// On Windows, use tasklist to check if process exists
try {
const command = new Deno.Command('tasklist', {
args: ['/FI', `PID eq ${pid}`, '/NH'],
stdout: 'piped',
const command = new Deno.Command("tasklist", {
args: ["/FI", `PID eq ${pid}`, "/NH"],
stdout: "piped",
});
const { stdout } = await command.output();
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> {
// 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) {
log('Lockfile is too old')
return false
log("Lockfile is too old");
return false;
}
// Check if the process is still running
if (!(await isPidRunning(lockData.pid))) {
log('Process from lockfile is not running')
return false
log("Process from lockfile is not running");
return false;
}
// Check if the endpoint is accessible
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 1000)
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1000);
const response = await fetch(`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`, {
signal: controller.signal,
})
const response = await fetch(
`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`,
{
signal: controller.signal,
},
);
clearTimeout(timeout)
return response.status === 200 || response.status === 202
clearTimeout(timeout);
return response.status === 200 || response.status === 202;
} catch (error) {
log(`Error connecting to auth server: ${(error as Error).message}`)
return false
log(`Error connecting to auth server: ${(error as Error).message}`);
return false;
}
}
@ -88,31 +97,31 @@ export async function isLockValid(lockData: LockfileData): Promise<boolean> {
* @returns True if authentication completed successfully, false otherwise
*/
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 {
while (true) {
const url = `http://127.0.0.1:${port}/wait-for-auth`
log(`Querying: ${url}`)
const response = await fetch(url)
const url = `http://127.0.0.1:${port}/wait-for-auth`;
log(`Querying: ${url}`);
const response = await fetch(url);
if (response.status === 200) {
// Auth completed, but we don't return the code anymore
log('Authentication completed by other instance')
return true
log("Authentication completed by other instance");
return true;
}
if (response.status === 202) {
// Continue polling
log('Authentication still in progress')
await new Promise((resolve) => setTimeout(resolve, 1000))
log("Authentication still in progress");
await new Promise((resolve) => setTimeout(resolve, 1000));
} else {
log(`Unexpected response status: ${response.status}`)
return false
log(`Unexpected response status: ${response.status}`);
return false;
}
}
} catch (error) {
log(`Error waiting for authentication: ${(error as Error).message}`)
return false
log(`Error waiting for authentication: ${(error as Error).message}`);
return false;
}
}
@ -127,72 +136,85 @@ export async function coordinateAuth(
serverUrlHash: string,
callbackPort: number,
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)
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 (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 to wait for the authentication to complete
const authCompleted = await waitForAuthentication(lockData.port)
const authCompleted = await waitForAuthentication(lockData.port);
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
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
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 new Promise<string>(() => {})
}
return new Promise<string>(() => {});
};
return {
server: dummyServer,
waitForAuthCode: dummyWaitForAuthCode,
skipBrowserAuth: true,
}
};
}
log('Taking over authentication process...')
log("Taking over authentication process...");
} 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
await deleteLockfile(serverUrlHash)
await deleteLockfile(serverUrlHash);
} else if (lockData) {
// Invalid lockfile, delete its
log('Found invalid lockfile, deleting it')
await deleteLockfile(serverUrlHash)
log("Found invalid lockfile, deleting it");
await deleteLockfile(serverUrlHash);
}
// Create our own lockfile
const { server, waitForAuthCode, authCompletedPromise: _ } = setupOAuthCallbackServerWithLongPoll({
port: callbackPort,
path: '/oauth/callback',
events,
})
const { server, waitForAuthCode, authCompletedPromise: _ } =
setupOAuthCallbackServerWithLongPoll({
port: callbackPort,
path: "/oauth/callback",
events,
});
// Get the actual port the server is running on
const address = server.address() as AddressInfo
const actualPort = address.port
const address = server.address() as AddressInfo;
const actualPort = address.port;
log(`Creating lockfile for server ${serverUrlHash} with process ${Deno.pid} on port ${actualPort}`)
await createLockfile(serverUrlHash, Deno.pid, actualPort)
log(
`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
const cleanupHandler = async () => {
try {
log(`Cleaning up lockfile for server ${serverUrlHash}`)
await deleteLockfile(serverUrlHash)
log(`Cleaning up lockfile for server ${serverUrlHash}`);
await deleteLockfile(serverUrlHash);
} catch (error) {
log(`Error cleaning up lockfile: ${error}`)
log(`Error cleaning up lockfile: ${error}`);
}
}
};
// Setup exit handlers for Deno
// Note: Deno doesn't have process.once but we can use addEventListener
@ -200,7 +222,7 @@ export async function coordinateAuth(
addEventListener("unload", () => {
try {
// Synchronous cleanup
const configPath = getConfigFilePath(serverUrlHash, 'lock.json')
const configPath = getConfigFilePath(serverUrlHash, "lock.json");
// Use Deno's synchronous file API
try {
Deno.removeSync(configPath);
@ -222,5 +244,5 @@ export async function coordinateAuth(
server,
waitForAuthCode,
skipBrowserAuth: false,
}
};
}

View file

@ -1,6 +1,6 @@
import path from 'node:path'
import os from 'node:os'
import { log, MCP_REMOTE_VERSION } from './utils.ts'
import path from "node:path";
import os from "node:os";
import { log, MCP_REMOTE_VERSION } from "./utils.ts";
/**
* MCP Remote Authentication Configuration
@ -26,9 +26,9 @@ import { log, MCP_REMOTE_VERSION } from './utils.ts'
* Lockfile data structure
*/
export interface LockfileData {
pid: number
port: number
timestamp: number
pid: number;
port: number;
timestamp: number;
}
/**
@ -37,13 +37,17 @@ export interface LockfileData {
* @param pid The process ID
* @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 = {
pid,
port,
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
* @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 {
const lockfile = await readJsonFile<LockfileData>(serverUrlHash, 'lock.json', {
async parseAsync(data: any) {
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 as LockfileData
const lockfile = await readJsonFile<LockfileData>(
serverUrlHash,
"lock.json",
{
parseAsync(data: unknown) {
if (typeof data !== "object" || data === null) return null;
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 {
return null
return null;
}
}
@ -73,7 +87,7 @@ export async function checkLockfile(serverUrlHash: string): Promise<LockfileData
* @param serverUrlHash The hash of the server URL
*/
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
*/
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
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> {
try {
const configDir = getConfigDir()
await Deno.mkdir(configDir, { recursive: true })
const configDir = getConfigDir();
await Deno.mkdir(configDir, { recursive: true });
} catch (error) {
log('Error creating config directory:', error)
throw error
log("Error creating config directory:", error);
throw error;
}
}
@ -105,9 +120,12 @@ export async function ensureConfigDir(): Promise<void> {
* @param filename The name of the file
* @returns The absolute file path
*/
export function getConfigFilePath(serverUrlHash: string, filename: string): string {
const configDir = getConfigDir()
return path.join(configDir, `${serverUrlHash}_${filename}`)
export function getConfigFilePath(
serverUrlHash: string,
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 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 {
const filePath = getConfigFilePath(serverUrlHash, filename)
await Deno.remove(filePath)
} catch (error) {
const filePath = getConfigFilePath(serverUrlHash, filename);
await Deno.remove(filePath);
} catch (_error) {
// Ignore if file doesn't exist
if ((error as Deno.errors.NotFound).name !== 'NotFound') {
log(`Error deleting ${filename}:`, error)
if ((_error as Deno.errors.NotFound).name !== "NotFound") {
log(`Error deleting ${filename}:`, _error);
}
}
}
@ -134,22 +155,25 @@ export async function deleteConfigFile(serverUrlHash: string, filename: string):
* @param schema The schema to validate against
* @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 {
await ensureConfigDir()
await ensureConfigDir();
const filePath = getConfigFilePath(serverUrlHash, filename)
const content = await Deno.readTextFile(filePath)
const result = await schema.parseAsync(JSON.parse(content))
// console.log({ filename: result })
return result
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
const filePath = getConfigFilePath(serverUrlHash, filename);
const content = await Deno.readTextFile(filePath);
const result = await schema.parseAsync(JSON.parse(content));
return result ?? undefined;
} catch (_error) {
if (_error instanceof Deno.errors.NotFound) {
// console.log(`File ${filename} does not exist`)
return undefined
return undefined;
}
log(`Error reading ${filename}:`, error)
return undefined
log(`Error reading ${filename}:`, _error);
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 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 {
await ensureConfigDir()
const filePath = getConfigFilePath(serverUrlHash, filename)
await Deno.writeTextFile(filePath, JSON.stringify(data, null, 2))
} catch (error) {
log(`Error writing ${filename}:`, error)
throw error
await ensureConfigDir();
const filePath = getConfigFilePath(serverUrlHash, filename);
await Deno.writeTextFile(filePath, JSON.stringify(data, null, 2));
} catch (_error) {
log(`Error writing ${filename}:`, _error);
throw _error;
}
}
@ -177,13 +205,17 @@ export async function writeJsonFile(serverUrlHash: string, filename: string, dat
* @param errorMessage Optional custom error message
* @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 {
await ensureConfigDir()
const filePath = getConfigFilePath(serverUrlHash, filename)
return await Deno.readTextFile(filePath)
} catch (error) {
throw new Error(errorMessage || `Error reading ${filename}`)
await ensureConfigDir();
const filePath = getConfigFilePath(serverUrlHash, filename);
return await Deno.readTextFile(filePath);
} catch (_error) {
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 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 {
await ensureConfigDir()
const filePath = getConfigFilePath(serverUrlHash, filename)
await Deno.writeTextFile(filePath, text)
await ensureConfigDir();
const filePath = getConfigFilePath(serverUrlHash, filename);
await Deno.writeTextFile(filePath, text);
} catch (error) {
log(`Error writing ${filename}:`, error)
throw error
log(`Error writing ${filename}:`, error);
throw error;
}
}

View file

@ -1,84 +1,108 @@
import open from 'npm:open'
import { OAuthClientProvider } from 'npm:@modelcontextprotocol/sdk/client/auth.js'
import open from "npm:open";
import type { OAuthClientProvider } from "npm:@modelcontextprotocol/sdk/client/auth.js";
import "npm:@modelcontextprotocol/sdk/client/auth.js";
import {
OAuthClientInformationSchema,
OAuthTokensSchema,
} from "npm:@modelcontextprotocol/sdk/shared/auth.js";
import type {
OAuthClientInformation,
OAuthClientInformationFull,
OAuthClientInformationSchema,
OAuthTokens,
OAuthTokensSchema,
} from 'npm:@modelcontextprotocol/sdk/shared/auth.js'
import type { OAuthProviderOptions } from './types.ts'
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile } from './mcp-auth-config.ts'
import { getServerUrlHash, log, MCP_REMOTE_VERSION } from './utils.ts'
} from "npm:@modelcontextprotocol/sdk/shared/auth.js";
import type { OAuthProviderOptions } from "./types.ts";
import {
readJsonFile,
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.
* Handles OAuth flow and token storage for MCP clients.
*/
export class NodeOAuthClientProvider implements OAuthClientProvider {
private serverUrlHash: string
private callbackPath: string
private clientName: string
private clientUri: string
private softwareId: string
private softwareVersion: string
private serverUrlHash: string;
private callbackPath: string;
private clientName: string;
private clientUri: string;
private softwareId: string;
private softwareVersion: string;
/**
* Creates a new NodeOAuthClientProvider
* @param options Configuration options for the provider
*/
constructor(readonly options: OAuthProviderOptions) {
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 || MCP_REMOTE_VERSION
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 || MCP_REMOTE_VERSION;
}
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() {
return {
redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
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,
}
};
}
/**
* Gets the client information if it exists
* @returns The client information or undefined
*/
async clientInformation(): Promise<OAuthClientInformation | undefined> {
clientInformation(): Promise<OAuthClientInformation | undefined> {
// 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
* @param clientInformation The client information to save
*/
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
async saveClientInformation(
clientInformation: OAuthClientInformationFull,
): Promise<void> {
// 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
* @returns The OAuth tokens or undefined
*/
async tokens(): Promise<OAuthTokens | undefined> {
tokens(): Promise<OAuthTokens | undefined> {
// log('Reading tokens')
// 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> {
// 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
*/
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 {
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.')
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.",
);
}
}
@ -110,7 +138,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
*/
async saveCodeVerifier(codeVerifier: string): Promise<void> {
// 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> {
// 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
*/
export interface OAuthProviderOptions {
/** Server URL to connect to */
serverUrl: string
serverUrl: string;
/** Port for the OAuth callback server */
callbackPort: number
callbackPort: number;
/** Path for the OAuth callback endpoint */
callbackPath?: string
callbackPath?: string;
/** Directory to store OAuth credentials */
configDir?: string
configDir?: string;
/** Client name to use for OAuth registration */
clientName?: string
clientName?: string;
/** Client URI to use for OAuth registration */
clientUri?: string
clientUri?: string;
/** Software ID to use for OAuth registration */
softwareId?: string
softwareId?: string;
/** Software version to use for OAuth registration */
softwareVersion?: string
softwareVersion?: string;
}
/**
@ -27,9 +27,9 @@ export interface OAuthProviderOptions {
*/
export interface OAuthCallbackServerOptions {
/** Port for the callback server */
port: number
port: number;
/** Path for the callback endpoint */
path: string
path: string;
/** 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 { SSEClientTransport } from 'npm:@modelcontextprotocol/sdk/client/sse.js'
import type { Transport } from 'npm:@modelcontextprotocol/sdk/shared/transport.js'
import type { OAuthCallbackServerOptions } from './types.ts'
import express from 'npm:express'
import net from 'node:net'
import crypto from 'node:crypto'
import {
type OAuthClientProvider,
UnauthorizedError,
} from "npm:@modelcontextprotocol/sdk/client/auth.js";
import { SSEClientTransport } from "npm:@modelcontextprotocol/sdk/client/sse.js";
import type { Transport } from "npm:@modelcontextprotocol/sdk/shared/transport.js";
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)
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[]) {
// 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
* @param params The transport connections to proxy between
*/
export function mcpProxy({ transportToClient, transportToServer }: { transportToClient: Transport; transportToServer: Transport }) {
let transportToClientClosed = false
let transportToServerClosed = false
export function mcpProxy(
{ transportToClient, transportToServer }: {
transportToClient: Transport;
transportToServer: Transport;
},
) {
let transportToClientClosed = false;
let transportToServerClosed = false;
transportToClient.onmessage = (message) => {
// @ts-expect-error TODO
log('[Local→Remote]', message.method || message.id)
transportToServer.send(message).catch(onServerError)
}
log("[Local→Remote]", message.method || message.id);
transportToServer.send(message).catch(onServerError);
};
transportToServer.onmessage = (message) => {
// @ts-expect-error TODO: fix this type
log('[Remote→Local]', message.method || message.id)
transportToClient.send(message).catch(onClientError)
}
log("[Remote→Local]", message.method || message.id);
transportToClient.send(message).catch(onClientError);
};
transportToClient.onclose = () => {
if (transportToServerClosed) {
return
return;
}
transportToClientClosed = true
transportToServer.close().catch(onServerError)
}
transportToClientClosed = true;
transportToServer.close().catch(onServerError);
};
transportToServer.onclose = () => {
if (transportToClientClosed) {
return
return;
}
transportToServerClosed = true
transportToClient.close().catch(onClientError)
}
transportToServerClosed = true;
transportToClient.close().catch(onClientError);
};
transportToClient.onerror = onClientError
transportToServer.onerror = onServerError
transportToClient.onerror = onClientError;
transportToServer.onerror = onServerError;
function onClientError(error: Error) {
log('Error from local client:', error)
log("Error from local client:", 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>,
skipBrowserAuth = false,
): Promise<SSEClientTransport> {
log(`[${pid}] Connecting to remote server: ${serverUrl}`)
const url = new URL(serverUrl)
log(`[${pid}] Connecting to remote server: ${serverUrl}`);
const url = new URL(serverUrl);
// Create transport with eventSourceInit to pass Authorization header if present
const eventSourceInit = {
@ -92,7 +100,9 @@ export async function connectToRemoteServer(
headers: {
...(init?.headers as Record<string, string> | undefined),
...headers,
...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}),
...(tokens?.access_token
? { Authorization: `Bearer ${tokens.access_token}` }
: {}),
Accept: "text/event-stream",
} as Record<string, string>,
})
@ -104,39 +114,47 @@ export async function connectToRemoteServer(
authProvider,
requestInit: { headers },
eventSourceInit,
})
});
try {
await transport.start()
log('Connected to remote server')
return transport
await transport.start();
log("Connected to remote server");
return transport;
} 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) {
log('Authentication required but skipping browser auth - using shared auth')
log(
"Authentication required but skipping browser auth - using shared auth",
);
} else {
log('Authentication required. Waiting for authorization...')
log("Authentication required. Waiting for authorization...");
}
// Wait for the authorization code from the callback
const code = await waitForAuthCode()
const code = await waitForAuthCode();
try {
log('Completing authorization...')
await transport.finishAuth(code)
log("Completing authorization...");
await transport.finishAuth(code);
// Create a new transport after auth
const newTransport = new SSEClientTransport(url, { authProvider, requestInit: { headers } })
await newTransport.start()
log('Connected to remote server after authentication')
return newTransport
const newTransport = new SSEClientTransport(url, {
authProvider,
requestInit: { headers },
});
await newTransport.start();
log("Connected to remote server after authentication");
return newTransport;
} catch (authError) {
log('Authorization error:', authError)
throw authError
log("Authorization error:", authError);
throw authError;
}
} else {
log('Connection error:', error)
throw error
log("Connection error:", error);
throw error;
}
}
}
@ -146,92 +164,96 @@ export async function connectToRemoteServer(
* @param options The server options
* @returns An object with the server, authCode, and waitForAuthCode function
*/
export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServerOptions) {
let authCode: string | null = null
const app = express()
export function setupOAuthCallbackServerWithLongPoll(
options: OAuthCallbackServerOptions,
) {
let authCode: string | null = null;
const app = express();
// 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) => {
authCompletedResolve = resolve
})
authCompletedResolve = resolve;
});
// Long-polling endpoint
app.get('/wait-for-auth', (req, res) => {
app.get("/wait-for-auth", (req, res) => {
if (authCode) {
// Auth already completed - just return 200 without the actual code
// Secondary instances will read tokens from disk
log('Auth already completed, returning 200')
res.status(200).send('Authentication completed')
return
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
if (req.query.poll === "false") {
log("Client requested no long poll, responding with 202");
res.status(202).send("Authentication in progress");
return;
}
// Long poll - wait for up to 30 seconds
const longPollTimeout = setTimeout(() => {
log('Long poll timeout reached, responding with 202')
res.status(202).send('Authentication in progress')
}, 30000)
log("Long poll timeout reached, responding with 202");
res.status(202).send("Authentication in progress");
}, 30000);
// If auth completes while we're waiting, send the response immediately
authCompletedPromise
.then(() => {
clearTimeout(longPollTimeout)
clearTimeout(longPollTimeout);
if (!res.headersSent) {
log('Auth completed during long poll, responding with 200')
res.status(200).send('Authentication completed')
log("Auth completed during long poll, responding with 200");
res.status(200).send("Authentication completed");
}
})
.catch(() => {
clearTimeout(longPollTimeout)
clearTimeout(longPollTimeout);
if (!res.headersSent) {
log('Auth failed during long poll, responding with 500')
res.status(500).send('Authentication failed')
log("Auth failed during long poll, responding with 500");
res.status(500).send("Authentication failed");
}
})
})
});
});
// OAuth callback endpoint
app.get(options.path, (req, res) => {
const code = req.query.code as string | undefined
const code = req.query.code as string | undefined;
if (!code) {
res.status(400).send('Error: No authorization code received')
return
res.status(400).send("Error: No authorization code received");
return;
}
authCode = code
log('Auth code received, resolving promise')
authCompletedResolve(code)
authCode = code;
log("Auth code received, resolving promise");
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
options.events.emit('auth-code-received', code)
})
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}`)
})
log(`OAuth callback server running at http://127.0.0.1:${options.port}`);
});
const waitForAuthCode = (): Promise<string> => {
return new Promise((resolve) => {
if (authCode) {
resolve(authCode)
return
resolve(authCode);
return;
}
options.events.once('auth-code-received', (code) => {
resolve(code)
})
})
}
options.events.once("auth-code-received", (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
*/
export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
const { server, authCode, waitForAuthCode } = setupOAuthCallbackServerWithLongPoll(options)
return { server, authCode, waitForAuthCode }
const { 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
* @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) => {
const server = net.createServer()
const server = net.createServer();
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
// If preferred port is in use, get a random port
server.listen(0)
server.listen(0);
} else {
reject(err)
reject(err);
}
})
});
server.on('listening', () => {
const { port } = server.address() as net.AddressInfo
server.on("listening", () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => {
resolve(port)
})
})
resolve(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
* @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
if (args.includes('--help') || args.includes('-h')) {
log(usage)
Deno.exit(0)
if (args.includes("--help") || args.includes("-h")) {
log(usage);
Deno.exit(0);
}
// Process headers
const headers: Record<string, string> = {}
const headers: Record<string, string> = {};
args.forEach((arg, i) => {
if (arg === '--header' && i < args.length - 1) {
const value = args[i + 1]
const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/)
if (arg === "--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]
headers[match[1]] = match[2];
} 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 specifiedPort = args[1] ? Number.parseInt(args[1], 10) : undefined
const allowHttp = args.includes('--allow-http')
const serverUrl = args[0];
const specifiedPort = args[1] ? Number.parseInt(args[1], 10) : undefined;
const allowHttp = args.includes("--allow-http");
if (!serverUrl) {
log(usage)
Deno.exit(1)
log(usage);
Deno.exit(1);
}
const url = new URL(serverUrl)
const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:'
const url = new URL(serverUrl);
const isLocalhost =
(url.hostname === "localhost" || url.hostname === "127.0.0.1") &&
url.protocol === "http:";
if (!(url.protocol === 'https:' || isLocalhost || allowHttp)) {
log('Error: Non-HTTPS URLs are only allowed for localhost or when --allow-http flag is provided')
log(usage)
Deno.exit(1)
if (!(url.protocol === "https:" || isLocalhost || allowHttp)) {
log(
"Error: Non-HTTPS URLs are only allowed for localhost or when --allow-http flag is provided",
);
log(usage);
Deno.exit(1);
}
// Use the specified port, or find an available one
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort))
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort));
if (specifiedPort) {
log(`Using specified callback port: ${callbackPort}`)
log(`Using specified callback port: ${callbackPort}`);
} else {
log(`Using automatically selected callback port: ${callbackPort}`)
log(`Using automatically selected callback port: ${callbackPort}`);
}
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
// example `Authorization: Bearer ${TOKEN}` will read process.env.TOKEN
for (const [key, value] of Object.entries(headers)) {
headers[key] = value.replace(/\$\{([^}]+)}/g, (match, envVarName) => {
const envVarValue = Deno.env.get(envVarName)
const envVarValue = Deno.env.get(envVarName);
if (envVarValue !== undefined) {
log(`Replacing ${match} with environment value in header '${key}'`)
return envVarValue
log(`Replacing ${match} with environment value in header '${key}'`);
return envVarValue;
}
log(`Warning: Environment variable '${envVarName}' not found for header '${key}'.`)
return ''
})
log(
`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>) {
Deno.addSignalListener("SIGINT", async () => {
log('\nShutting down...')
await cleanup()
Deno.exit(0)
})
log("\nShutting down...");
await cleanup();
Deno.exit(0);
});
// For SIGTERM
try {
Deno.addSignalListener("SIGTERM", async () => {
log('\nReceived SIGTERM. Shutting down...')
await cleanup()
Deno.exit(0)
})
} catch (e) {
log("\nReceived SIGTERM. Shutting down...");
await cleanup();
Deno.exit(0);
});
} catch (_e) {
// 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
*/
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.
*/
import { EventEmitter } from 'node:events'
import { StdioServerTransport } from 'npm:@modelcontextprotocol/sdk/server/stdio.js'
import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash } from './lib/utils.ts'
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider.ts'
import { coordinateAuth } from './lib/coordination.ts'
import { EventEmitter } from "node:events";
import { StdioServerTransport } from "npm:@modelcontextprotocol/sdk/server/stdio.js";
import {
connectToRemoteServer,
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
*/
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
const events = new EventEmitter()
const events = new EventEmitter();
// Get the server URL hash for lockfile operations
const serverUrlHash = getServerUrlHash(serverUrl)
const serverUrlHash = getServerUrlHash(serverUrl);
// 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
const authProvider = new NodeOAuthClientProvider({
serverUrl,
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 (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
// 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
const localTransport = new StdioServerTransport()
const localTransport = new StdioServerTransport();
try {
// 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
mcpProxy({
transportToClient: localTransport,
transportToServer: remoteTransport,
})
});
// Start the local STDIO server
await localTransport.start()
log('Local STDIO server running')
log('Proxy established successfully between local STDIO and remote SSE')
log('Press Ctrl+C to exit')
await localTransport.start();
log("Local STDIO server running");
log("Proxy established successfully between local STDIO and remote SSE");
log("Press Ctrl+C to exit");
// Setup cleanup handler
const cleanup = async () => {
await remoteTransport.close()
await localTransport.close()
server.close()
}
setupSignalHandlers(cleanup)
await remoteTransport.close();
await localTransport.close();
server.close();
};
setupSignalHandlers(cleanup);
} catch (error) {
log('Fatal error:', error)
if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) {
log("Fatal error:", error);
if (
error instanceof Error &&
error.message.includes("self-signed certificate in certificate chain")
) {
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
@ -92,19 +118,23 @@ to the CA certificate file. If using claude_desktop_config.json, this might look
}
}
}
`)
`);
}
server.close()
Deno.exit(1)
server.close();
Deno.exit(1);
}
}
// 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 }) => {
return runProxy(serverUrl, callbackPort, headers)
return runProxy(serverUrl, callbackPort, headers);
})
.catch((error) => {
log('Fatal error:', error)
Deno.exit(1)
})
log("Fatal error:", error);
Deno.exit(1);
});