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:
parent
00b1d15cfd
commit
4394c0773d
10 changed files with 1815 additions and 1096 deletions
185
src/client.ts
185
src/client.ts
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
349
src/lib/utils.ts
349
src/lib/utils.ts
|
@ -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");
|
||||
}
|
||||
|
|
100
src/proxy.ts
100
src/proxy.ts
|
@ -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);
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue