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
1
.denoignore
Normal file
1
.denoignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
*.md
|
|
@ -30,4 +30,21 @@ Here is a plan to transform your Node.js CLI package into a Deno CLI project, fo
|
||||||
- [x] Run the main task using `deno task proxy:start <args...>`.
|
- [x] Run the main task using `deno task proxy:start <args...>`.
|
||||||
- [x] Thoroughly test the CLI's functionality to ensure it behaves identically to the original Node.js version. Pay close attention to areas involving file system access, network requests, environment variables, and process management.
|
- [x] Thoroughly test the CLI's functionality to ensure it behaves identically to the original Node.js version. Pay close attention to areas involving file system access, network requests, environment variables, and process management.
|
||||||
|
|
||||||
|
6. **Refine Type Safety & Linting:**
|
||||||
|
- [x] Address remaining `any` types and other linter warnings identified by `deno lint`.
|
||||||
|
- [x] Improve type definitions, especially for external library interactions (e.g., express request/response types if kept).
|
||||||
|
- [x] Run `deno fmt` to ensure consistent code formatting.
|
||||||
|
7. **Improve Dependency Management:**
|
||||||
|
- [ ] Evaluate replacing `npm:express` with a native Deno HTTP server solution (e.g., `Deno.serve` or from `std/http`).
|
||||||
|
- [ ] Evaluate replacing `npm:open` with a Deno equivalent or platform-specific commands.
|
||||||
|
8. **Implement Testing:**
|
||||||
|
- [ ] Add unit tests for key utility functions (e.g., in `utils.ts`, `mcp-auth-config.ts`).
|
||||||
|
- [ ] Add integration tests for the core proxy (`proxy.ts`) and client (`client.ts`) functionality.
|
||||||
|
9. **Enhance Documentation:**
|
||||||
|
- [ ] Update `README.md` with Deno-specific installation, usage, and contribution guidelines.
|
||||||
|
- [ ] Add comprehensive TSDoc comments to all exported functions, classes, and interfaces.
|
||||||
|
10. **Build & Distribution:**
|
||||||
|
- [ ] Configure `deno publish` settings if intending to publish to deno.land/x.
|
||||||
|
- [ ] Explore using `deno compile` to create standalone executables for different platforms.
|
||||||
|
|
||||||
This plan prioritizes modifying the existing TypeScript code minimally while adapting the project structure and configuration for Deno. We will start by modifying `deno.json` and `src/proxy.ts`.
|
This plan prioritizes modifying the existing TypeScript code minimally while adapting the project structure and configuration for Deno. We will start by modifying `deno.json` and `src/proxy.ts`.
|
||||||
|
|
1793
pnpm-lock.yaml
generated
1793
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
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.
|
* If callback-port is not specified, an available port will be automatically selected.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from "node:events";
|
||||||
import { Client } from 'npm:@modelcontextprotocol/sdk/client/index.js'
|
import { Client } from "npm:@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { SSEClientTransport } from 'npm:@modelcontextprotocol/sdk/client/sse.js'
|
import { SSEClientTransport } from "npm:@modelcontextprotocol/sdk/client/sse.js";
|
||||||
import { ListResourcesResultSchema, ListToolsResultSchema } from 'npm:@modelcontextprotocol/sdk/types.js'
|
import {
|
||||||
import { UnauthorizedError } from 'npm:@modelcontextprotocol/sdk/client/auth.js'
|
ListResourcesResultSchema,
|
||||||
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider.ts'
|
ListToolsResultSchema,
|
||||||
import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, getServerUrlHash } from './lib/utils.ts'
|
} from "npm:@modelcontextprotocol/sdk/types.js";
|
||||||
import { coordinateAuth } from './lib/coordination.ts'
|
import { UnauthorizedError } from "npm:@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
import { NodeOAuthClientProvider } from "./lib/node-oauth-client-provider.ts";
|
||||||
|
import {
|
||||||
|
getServerUrlHash,
|
||||||
|
log,
|
||||||
|
MCP_REMOTE_VERSION,
|
||||||
|
parseCommandLineArgs,
|
||||||
|
setupSignalHandlers,
|
||||||
|
} from "./lib/utils.ts";
|
||||||
|
import { coordinateAuth } from "./lib/coordination.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main function to run the client
|
* Main function to run the client
|
||||||
*/
|
*/
|
||||||
async function runClient(serverUrl: string, callbackPort: number, headers: Record<string, string>) {
|
async function runClient(
|
||||||
|
serverUrl: string,
|
||||||
|
callbackPort: number,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
) {
|
||||||
// Set up event emitter for auth flow
|
// Set up event emitter for auth flow
|
||||||
const events = new EventEmitter()
|
const events = new EventEmitter();
|
||||||
|
|
||||||
// Get the server URL hash for lockfile operations
|
// Get the server URL hash for lockfile operations
|
||||||
const serverUrlHash = getServerUrlHash(serverUrl)
|
const serverUrlHash = getServerUrlHash(serverUrl);
|
||||||
|
|
||||||
// Coordinate authentication with other instances
|
// Coordinate authentication with other instances
|
||||||
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events)
|
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(
|
||||||
|
serverUrlHash,
|
||||||
|
callbackPort,
|
||||||
|
events,
|
||||||
|
);
|
||||||
|
|
||||||
// Create the OAuth client provider
|
// Create the OAuth client provider
|
||||||
const authProvider = new NodeOAuthClientProvider({
|
const authProvider = new NodeOAuthClientProvider({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
callbackPort,
|
callbackPort,
|
||||||
clientName: 'MCP CLI Client',
|
clientName: "MCP CLI Client",
|
||||||
})
|
});
|
||||||
|
|
||||||
// If auth was completed by another instance, just log that we'll use the auth from disk
|
// If auth was completed by another instance, just log that we'll use the auth from disk
|
||||||
if (skipBrowserAuth) {
|
if (skipBrowserAuth) {
|
||||||
log('Authentication was completed by another instance - will use tokens from disk...')
|
log(
|
||||||
|
"Authentication was completed by another instance - will use tokens from disk...",
|
||||||
|
);
|
||||||
// TODO: remove, the callback is happening before the tokens are exchanged
|
// TODO: remove, the callback is happening before the tokens are exchanged
|
||||||
// so we're slightly too early
|
// so we're slightly too early
|
||||||
await new Promise((res) => setTimeout(res, 1_000))
|
await new Promise((res) => setTimeout(res, 1_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the client
|
// Create the client
|
||||||
const client = new Client(
|
const client = new Client(
|
||||||
{
|
{
|
||||||
name: 'mcp-remote',
|
name: "mcp-remote",
|
||||||
version: MCP_REMOTE_VERSION,
|
version: MCP_REMOTE_VERSION,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// Create the transport factory
|
// Create the transport factory
|
||||||
const url = new URL(serverUrl)
|
const url = new URL(serverUrl);
|
||||||
function initTransport() {
|
function initTransport() {
|
||||||
const transport = new SSEClientTransport(url, { authProvider, requestInit: { headers } })
|
const transport = new SSEClientTransport(url, {
|
||||||
|
authProvider,
|
||||||
|
requestInit: { headers },
|
||||||
|
});
|
||||||
|
|
||||||
// Set up message and error handlers
|
// Set up message and error handlers
|
||||||
transport.onmessage = (message) => {
|
transport.onmessage = (message) => {
|
||||||
log('Received message:', JSON.stringify(message, null, 2))
|
log("Received message:", JSON.stringify(message, null, 2));
|
||||||
}
|
};
|
||||||
|
|
||||||
transport.onerror = (error) => {
|
transport.onerror = (error) => {
|
||||||
log('Transport error:', error)
|
log("Transport error:", error);
|
||||||
}
|
};
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
log('Connection closed.')
|
log("Connection closed.");
|
||||||
Deno.exit(0)
|
Deno.exit(0);
|
||||||
}
|
};
|
||||||
return transport
|
return transport;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transport = initTransport()
|
const transport = initTransport();
|
||||||
|
|
||||||
// Set up cleanup handler
|
// Set up cleanup handler
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
log('\nClosing connection...')
|
log("\nClosing connection...");
|
||||||
await client.close()
|
await client.close();
|
||||||
server.close()
|
server.close();
|
||||||
}
|
};
|
||||||
setupSignalHandlers(cleanup)
|
setupSignalHandlers(cleanup);
|
||||||
|
|
||||||
// Try to connect
|
// Try to connect
|
||||||
try {
|
try {
|
||||||
log('Connecting to server...')
|
log("Connecting to server...");
|
||||||
await client.connect(transport)
|
await client.connect(transport);
|
||||||
log('Connected successfully!')
|
log("Connected successfully!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
if (
|
||||||
log('Authentication required. Waiting for authorization...')
|
error instanceof UnauthorizedError ||
|
||||||
|
(error instanceof Error && error.message.includes("Unauthorized"))
|
||||||
|
) {
|
||||||
|
log("Authentication required. Waiting for authorization...");
|
||||||
|
|
||||||
// Wait for the authorization code from the callback or another instance
|
// Wait for the authorization code from the callback or another instance
|
||||||
const code = await waitForAuthCode()
|
const code = await waitForAuthCode();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log('Completing authorization...')
|
log("Completing authorization...");
|
||||||
await transport.finishAuth(code)
|
await transport.finishAuth(code);
|
||||||
|
|
||||||
// Reconnect after authorization with a new transport
|
// Reconnect after authorization with a new transport
|
||||||
log('Connecting after authorization...')
|
log("Connecting after authorization...");
|
||||||
await client.connect(initTransport())
|
await client.connect(initTransport());
|
||||||
|
|
||||||
log('Connected successfully!')
|
log("Connected successfully!");
|
||||||
|
|
||||||
// Request tools list after auth
|
// Request tools list after auth
|
||||||
log('Requesting tools list...')
|
log("Requesting tools list...");
|
||||||
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
const tools = await client.request(
|
||||||
log('Tools:', JSON.stringify(tools, null, 2))
|
{ method: "tools/list" },
|
||||||
|
ListToolsResultSchema,
|
||||||
|
);
|
||||||
|
log("Tools:", JSON.stringify(tools, null, 2));
|
||||||
|
|
||||||
// Request resources list after auth
|
// Request resources list after auth
|
||||||
log('Requesting resource list...')
|
log("Requesting resource list...");
|
||||||
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
const resources = await client.request(
|
||||||
log('Resources:', JSON.stringify(resources, null, 2))
|
{ method: "resources/list" },
|
||||||
|
ListResourcesResultSchema,
|
||||||
|
);
|
||||||
|
log("Resources:", JSON.stringify(resources, null, 2));
|
||||||
|
|
||||||
log('Listening for messages. Press Ctrl+C to exit.')
|
log("Listening for messages. Press Ctrl+C to exit.");
|
||||||
} catch (authError) {
|
} catch (authError) {
|
||||||
log('Authorization error:', authError)
|
log("Authorization error:", authError);
|
||||||
server.close()
|
server.close();
|
||||||
Deno.exit(1)
|
Deno.exit(1);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log('Connection error:', error)
|
log("Connection error:", error);
|
||||||
server.close()
|
server.close();
|
||||||
Deno.exit(1)
|
Deno.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request tools list
|
// Request tools list
|
||||||
log('Requesting tools list...')
|
log("Requesting tools list...");
|
||||||
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
const tools = await client.request(
|
||||||
log('Tools:', JSON.stringify(tools, null, 2))
|
{ method: "tools/list" },
|
||||||
|
ListToolsResultSchema,
|
||||||
|
);
|
||||||
|
log("Tools:", JSON.stringify(tools, null, 2));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Error requesting tools list:', e)
|
log("Error requesting tools list:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request resources list
|
// Request resources list
|
||||||
log('Requesting resource list...')
|
log("Requesting resource list...");
|
||||||
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
const resources = await client.request(
|
||||||
log('Resources:', JSON.stringify(resources, null, 2))
|
{ method: "resources/list" },
|
||||||
|
ListResourcesResultSchema,
|
||||||
|
);
|
||||||
|
log("Resources:", JSON.stringify(resources, null, 2));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Error requesting resources list:', e)
|
log("Error requesting resources list:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Listening for messages. Press Ctrl+C to exit.')
|
log("Listening for messages. Press Ctrl+C to exit.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse command-line arguments and run the client
|
// Parse command-line arguments and run the client
|
||||||
parseCommandLineArgs(Deno.args, 3333, 'Usage: deno run src/client.ts <https://server-url> [callback-port]')
|
parseCommandLineArgs(
|
||||||
|
Deno.args,
|
||||||
|
3333,
|
||||||
|
"Usage: deno run src/client.ts <https://server-url> [callback-port]",
|
||||||
|
)
|
||||||
.then(({ serverUrl, callbackPort, headers }) => {
|
.then(({ serverUrl, callbackPort, headers }) => {
|
||||||
return runClient(serverUrl, callbackPort, headers)
|
return runClient(serverUrl, callbackPort, headers);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Fatal error:', error)
|
console.error("Fatal error:", error);
|
||||||
Deno.exit(1)
|
Deno.exit(1);
|
||||||
})
|
});
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
import { checkLockfile, createLockfile, deleteLockfile, getConfigFilePath, type LockfileData } from './mcp-auth-config.ts'
|
import {
|
||||||
import type { EventEmitter } from 'node:events'
|
checkLockfile,
|
||||||
import type { Server } from 'node:http'
|
createLockfile,
|
||||||
import express from 'npm:express'
|
deleteLockfile,
|
||||||
import type { AddressInfo } from 'node:net'
|
getConfigFilePath,
|
||||||
import { log, setupOAuthCallbackServerWithLongPoll } from './utils.ts'
|
type LockfileData,
|
||||||
|
} from "./mcp-auth-config.ts";
|
||||||
|
import type { EventEmitter } from "node:events";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
import express from "npm:express";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { log, setupOAuthCallbackServerWithLongPoll } from "./utils.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a process with the given PID is running
|
* Checks if a process with the given PID is running
|
||||||
|
@ -14,13 +20,13 @@ export async function isPidRunning(pid: number): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Deno doesn't have a direct equivalent to process.kill(pid, 0)
|
// Deno doesn't have a direct equivalent to process.kill(pid, 0)
|
||||||
// On non-Windows platforms, we can try to use kill system call to check
|
// On non-Windows platforms, we can try to use kill system call to check
|
||||||
if (Deno.build.os !== 'windows') {
|
if (Deno.build.os !== "windows") {
|
||||||
try {
|
try {
|
||||||
// Using Deno.run to check if process exists
|
// Using Deno.run to check if process exists
|
||||||
const command = new Deno.Command('kill', {
|
const command = new Deno.Command("kill", {
|
||||||
args: ['-0', pid.toString()],
|
args: ["-0", pid.toString()],
|
||||||
stdout: 'null',
|
stdout: "null",
|
||||||
stderr: 'null',
|
stderr: "null",
|
||||||
});
|
});
|
||||||
const { success } = await command.output();
|
const { success } = await command.output();
|
||||||
return success;
|
return success;
|
||||||
|
@ -30,9 +36,9 @@ export async function isPidRunning(pid: number): Promise<boolean> {
|
||||||
} else {
|
} else {
|
||||||
// On Windows, use tasklist to check if process exists
|
// On Windows, use tasklist to check if process exists
|
||||||
try {
|
try {
|
||||||
const command = new Deno.Command('tasklist', {
|
const command = new Deno.Command("tasklist", {
|
||||||
args: ['/FI', `PID eq ${pid}`, '/NH'],
|
args: ["/FI", `PID eq ${pid}`, "/NH"],
|
||||||
stdout: 'piped',
|
stdout: "piped",
|
||||||
});
|
});
|
||||||
const { stdout } = await command.output();
|
const { stdout } = await command.output();
|
||||||
const output = new TextDecoder().decode(stdout);
|
const output = new TextDecoder().decode(stdout);
|
||||||
|
@ -53,32 +59,35 @@ export async function isPidRunning(pid: number): Promise<boolean> {
|
||||||
*/
|
*/
|
||||||
export async function isLockValid(lockData: LockfileData): Promise<boolean> {
|
export async function isLockValid(lockData: LockfileData): Promise<boolean> {
|
||||||
// Check if the lockfile is too old (over 30 minutes)
|
// Check if the lockfile is too old (over 30 minutes)
|
||||||
const MAX_LOCK_AGE = 30 * 60 * 1000 // 30 minutes
|
const MAX_LOCK_AGE = 30 * 60 * 1000; // 30 minutes
|
||||||
if (Date.now() - lockData.timestamp > MAX_LOCK_AGE) {
|
if (Date.now() - lockData.timestamp > MAX_LOCK_AGE) {
|
||||||
log('Lockfile is too old')
|
log("Lockfile is too old");
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the process is still running
|
// Check if the process is still running
|
||||||
if (!(await isPidRunning(lockData.pid))) {
|
if (!(await isPidRunning(lockData.pid))) {
|
||||||
log('Process from lockfile is not running')
|
log("Process from lockfile is not running");
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the endpoint is accessible
|
// Check if the endpoint is accessible
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 1000)
|
const timeout = setTimeout(() => controller.abort(), 1000);
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`, {
|
const response = await fetch(
|
||||||
signal: controller.signal,
|
`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`,
|
||||||
})
|
{
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout);
|
||||||
return response.status === 200 || response.status === 202
|
return response.status === 200 || response.status === 202;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`Error connecting to auth server: ${(error as Error).message}`)
|
log(`Error connecting to auth server: ${(error as Error).message}`);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,31 +97,31 @@ export async function isLockValid(lockData: LockfileData): Promise<boolean> {
|
||||||
* @returns True if authentication completed successfully, false otherwise
|
* @returns True if authentication completed successfully, false otherwise
|
||||||
*/
|
*/
|
||||||
export async function waitForAuthentication(port: number): Promise<boolean> {
|
export async function waitForAuthentication(port: number): Promise<boolean> {
|
||||||
log(`Waiting for authentication from the server on port ${port}...`)
|
log(`Waiting for authentication from the server on port ${port}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const url = `http://127.0.0.1:${port}/wait-for-auth`
|
const url = `http://127.0.0.1:${port}/wait-for-auth`;
|
||||||
log(`Querying: ${url}`)
|
log(`Querying: ${url}`);
|
||||||
const response = await fetch(url)
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
// Auth completed, but we don't return the code anymore
|
// Auth completed, but we don't return the code anymore
|
||||||
log('Authentication completed by other instance')
|
log("Authentication completed by other instance");
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
if (response.status === 202) {
|
if (response.status === 202) {
|
||||||
// Continue polling
|
// Continue polling
|
||||||
log('Authentication still in progress')
|
log("Authentication still in progress");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
} else {
|
} else {
|
||||||
log(`Unexpected response status: ${response.status}`)
|
log(`Unexpected response status: ${response.status}`);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`Error waiting for authentication: ${(error as Error).message}`)
|
log(`Error waiting for authentication: ${(error as Error).message}`);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,72 +136,85 @@ export async function coordinateAuth(
|
||||||
serverUrlHash: string,
|
serverUrlHash: string,
|
||||||
callbackPort: number,
|
callbackPort: number,
|
||||||
events: EventEmitter,
|
events: EventEmitter,
|
||||||
): Promise<{ server: Server; waitForAuthCode: () => Promise<string>; skipBrowserAuth: boolean }> {
|
): Promise<
|
||||||
|
{
|
||||||
|
server: Server;
|
||||||
|
waitForAuthCode: () => Promise<string>;
|
||||||
|
skipBrowserAuth: boolean;
|
||||||
|
}
|
||||||
|
> {
|
||||||
// Check for a lockfile (disabled on Windows for the time being)
|
// Check for a lockfile (disabled on Windows for the time being)
|
||||||
const lockData = Deno.build.os === 'windows' ? null : await checkLockfile(serverUrlHash)
|
const lockData = Deno.build.os === "windows"
|
||||||
|
? null
|
||||||
|
: await checkLockfile(serverUrlHash);
|
||||||
|
|
||||||
// If there's a valid lockfile, try to use the existing auth process
|
// If there's a valid lockfile, try to use the existing auth process
|
||||||
if (lockData && (await isLockValid(lockData))) {
|
if (lockData && (await isLockValid(lockData))) {
|
||||||
log(`Another instance is handling authentication on port ${lockData.port}`)
|
log(`Another instance is handling authentication on port ${lockData.port}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to wait for the authentication to complete
|
// Try to wait for the authentication to complete
|
||||||
const authCompleted = await waitForAuthentication(lockData.port)
|
const authCompleted = await waitForAuthentication(lockData.port);
|
||||||
if (authCompleted) {
|
if (authCompleted) {
|
||||||
log('Authentication completed by another instance')
|
log("Authentication completed by another instance");
|
||||||
|
|
||||||
// Setup a dummy server - the client will use tokens directly from disk
|
// Setup a dummy server - the client will use tokens directly from disk
|
||||||
const dummyServer = express().listen(0) // Listen on any available port
|
const dummyServer = express().listen(0); // Listen on any available port
|
||||||
|
|
||||||
// This shouldn't actually be called in normal operation, but provide it for API compatibility
|
// This shouldn't actually be called in normal operation, but provide it for API compatibility
|
||||||
const dummyWaitForAuthCode = () => {
|
const dummyWaitForAuthCode = () => {
|
||||||
log('WARNING: waitForAuthCode called in secondary instance - this is unexpected')
|
log(
|
||||||
|
"WARNING: waitForAuthCode called in secondary instance - this is unexpected",
|
||||||
|
);
|
||||||
// Return a promise that never resolves - the client should use the tokens from disk instead
|
// Return a promise that never resolves - the client should use the tokens from disk instead
|
||||||
return new Promise<string>(() => {})
|
return new Promise<string>(() => {});
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server: dummyServer,
|
server: dummyServer,
|
||||||
waitForAuthCode: dummyWaitForAuthCode,
|
waitForAuthCode: dummyWaitForAuthCode,
|
||||||
skipBrowserAuth: true,
|
skipBrowserAuth: true,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
log('Taking over authentication process...')
|
log("Taking over authentication process...");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`Error waiting for authentication: ${error}`)
|
log(`Error waiting for authentication: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, the other process didn't complete auth successfully
|
// If we get here, the other process didn't complete auth successfully
|
||||||
await deleteLockfile(serverUrlHash)
|
await deleteLockfile(serverUrlHash);
|
||||||
} else if (lockData) {
|
} else if (lockData) {
|
||||||
// Invalid lockfile, delete its
|
// Invalid lockfile, delete its
|
||||||
log('Found invalid lockfile, deleting it')
|
log("Found invalid lockfile, deleting it");
|
||||||
await deleteLockfile(serverUrlHash)
|
await deleteLockfile(serverUrlHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create our own lockfile
|
// Create our own lockfile
|
||||||
const { server, waitForAuthCode, authCompletedPromise: _ } = setupOAuthCallbackServerWithLongPoll({
|
const { server, waitForAuthCode, authCompletedPromise: _ } =
|
||||||
port: callbackPort,
|
setupOAuthCallbackServerWithLongPoll({
|
||||||
path: '/oauth/callback',
|
port: callbackPort,
|
||||||
events,
|
path: "/oauth/callback",
|
||||||
})
|
events,
|
||||||
|
});
|
||||||
|
|
||||||
// Get the actual port the server is running on
|
// Get the actual port the server is running on
|
||||||
const address = server.address() as AddressInfo
|
const address = server.address() as AddressInfo;
|
||||||
const actualPort = address.port
|
const actualPort = address.port;
|
||||||
|
|
||||||
log(`Creating lockfile for server ${serverUrlHash} with process ${Deno.pid} on port ${actualPort}`)
|
log(
|
||||||
await createLockfile(serverUrlHash, Deno.pid, actualPort)
|
`Creating lockfile for server ${serverUrlHash} with process ${Deno.pid} on port ${actualPort}`,
|
||||||
|
);
|
||||||
|
await createLockfile(serverUrlHash, Deno.pid, actualPort);
|
||||||
|
|
||||||
// Make sure lockfile is deleted on process exit
|
// Make sure lockfile is deleted on process exit
|
||||||
const cleanupHandler = async () => {
|
const cleanupHandler = async () => {
|
||||||
try {
|
try {
|
||||||
log(`Cleaning up lockfile for server ${serverUrlHash}`)
|
log(`Cleaning up lockfile for server ${serverUrlHash}`);
|
||||||
await deleteLockfile(serverUrlHash)
|
await deleteLockfile(serverUrlHash);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`Error cleaning up lockfile: ${error}`)
|
log(`Error cleaning up lockfile: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Setup exit handlers for Deno
|
// Setup exit handlers for Deno
|
||||||
// Note: Deno doesn't have process.once but we can use addEventListener
|
// Note: Deno doesn't have process.once but we can use addEventListener
|
||||||
|
@ -200,7 +222,7 @@ export async function coordinateAuth(
|
||||||
addEventListener("unload", () => {
|
addEventListener("unload", () => {
|
||||||
try {
|
try {
|
||||||
// Synchronous cleanup
|
// Synchronous cleanup
|
||||||
const configPath = getConfigFilePath(serverUrlHash, 'lock.json')
|
const configPath = getConfigFilePath(serverUrlHash, "lock.json");
|
||||||
// Use Deno's synchronous file API
|
// Use Deno's synchronous file API
|
||||||
try {
|
try {
|
||||||
Deno.removeSync(configPath);
|
Deno.removeSync(configPath);
|
||||||
|
@ -222,5 +244,5 @@ export async function coordinateAuth(
|
||||||
server,
|
server,
|
||||||
waitForAuthCode,
|
waitForAuthCode,
|
||||||
skipBrowserAuth: false,
|
skipBrowserAuth: false,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import path from 'node:path'
|
import path from "node:path";
|
||||||
import os from 'node:os'
|
import os from "node:os";
|
||||||
import { log, MCP_REMOTE_VERSION } from './utils.ts'
|
import { log, MCP_REMOTE_VERSION } from "./utils.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP Remote Authentication Configuration
|
* MCP Remote Authentication Configuration
|
||||||
|
@ -26,9 +26,9 @@ import { log, MCP_REMOTE_VERSION } from './utils.ts'
|
||||||
* Lockfile data structure
|
* Lockfile data structure
|
||||||
*/
|
*/
|
||||||
export interface LockfileData {
|
export interface LockfileData {
|
||||||
pid: number
|
pid: number;
|
||||||
port: number
|
port: number;
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -37,13 +37,17 @@ export interface LockfileData {
|
||||||
* @param pid The process ID
|
* @param pid The process ID
|
||||||
* @param port The port the server is running on
|
* @param port The port the server is running on
|
||||||
*/
|
*/
|
||||||
export async function createLockfile(serverUrlHash: string, pid: number, port: number): Promise<void> {
|
export async function createLockfile(
|
||||||
|
serverUrlHash: string,
|
||||||
|
pid: number,
|
||||||
|
port: number,
|
||||||
|
): Promise<void> {
|
||||||
const lockData: LockfileData = {
|
const lockData: LockfileData = {
|
||||||
pid,
|
pid,
|
||||||
port,
|
port,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}
|
};
|
||||||
await writeJsonFile(serverUrlHash, 'lock.json', lockData)
|
await writeJsonFile(serverUrlHash, "lock.json", lockData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -51,20 +55,30 @@ export async function createLockfile(serverUrlHash: string, pid: number, port: n
|
||||||
* @param serverUrlHash The hash of the server URL
|
* @param serverUrlHash The hash of the server URL
|
||||||
* @returns The lockfile data or null if it doesn't exist
|
* @returns The lockfile data or null if it doesn't exist
|
||||||
*/
|
*/
|
||||||
export async function checkLockfile(serverUrlHash: string): Promise<LockfileData | null> {
|
export async function checkLockfile(
|
||||||
|
serverUrlHash: string,
|
||||||
|
): Promise<LockfileData | null> {
|
||||||
try {
|
try {
|
||||||
const lockfile = await readJsonFile<LockfileData>(serverUrlHash, 'lock.json', {
|
const lockfile = await readJsonFile<LockfileData>(
|
||||||
async parseAsync(data: any) {
|
serverUrlHash,
|
||||||
if (typeof data !== 'object' || data === null) return null
|
"lock.json",
|
||||||
if (typeof data.pid !== 'number' || typeof data.port !== 'number' || typeof data.timestamp !== 'number') {
|
{
|
||||||
return null
|
parseAsync(data: unknown) {
|
||||||
}
|
if (typeof data !== "object" || data === null) return null;
|
||||||
return data as LockfileData
|
if (
|
||||||
|
typeof (data as LockfileData).pid !== "number" ||
|
||||||
|
typeof (data as LockfileData).port !== "number" ||
|
||||||
|
typeof (data as LockfileData).timestamp !== "number"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data as LockfileData;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
);
|
||||||
return lockfile || null
|
return lockfile || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,7 +87,7 @@ export async function checkLockfile(serverUrlHash: string): Promise<LockfileData
|
||||||
* @param serverUrlHash The hash of the server URL
|
* @param serverUrlHash The hash of the server URL
|
||||||
*/
|
*/
|
||||||
export async function deleteLockfile(serverUrlHash: string): Promise<void> {
|
export async function deleteLockfile(serverUrlHash: string): Promise<void> {
|
||||||
await deleteConfigFile(serverUrlHash, 'lock.json')
|
await deleteConfigFile(serverUrlHash, "lock.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -81,9 +95,10 @@ export async function deleteLockfile(serverUrlHash: string): Promise<void> {
|
||||||
* @returns The path to the configuration directory
|
* @returns The path to the configuration directory
|
||||||
*/
|
*/
|
||||||
export function getConfigDir(): string {
|
export function getConfigDir(): string {
|
||||||
const baseConfigDir = Deno.env.get('MCP_REMOTE_CONFIG_DIR') || path.join(os.homedir(), '.mcp-auth')
|
const baseConfigDir = Deno.env.get("MCP_REMOTE_CONFIG_DIR") ||
|
||||||
|
path.join(os.homedir(), ".mcp-auth");
|
||||||
// Add a version subdirectory so we don't need to worry about backwards/forwards compatibility yet
|
// Add a version subdirectory so we don't need to worry about backwards/forwards compatibility yet
|
||||||
return path.join(baseConfigDir, `mcp-remote-${MCP_REMOTE_VERSION}`)
|
return path.join(baseConfigDir, `mcp-remote-${MCP_REMOTE_VERSION}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -91,11 +106,11 @@ export function getConfigDir(): string {
|
||||||
*/
|
*/
|
||||||
export async function ensureConfigDir(): Promise<void> {
|
export async function ensureConfigDir(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const configDir = getConfigDir()
|
const configDir = getConfigDir();
|
||||||
await Deno.mkdir(configDir, { recursive: true })
|
await Deno.mkdir(configDir, { recursive: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('Error creating config directory:', error)
|
log("Error creating config directory:", error);
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,9 +120,12 @@ export async function ensureConfigDir(): Promise<void> {
|
||||||
* @param filename The name of the file
|
* @param filename The name of the file
|
||||||
* @returns The absolute file path
|
* @returns The absolute file path
|
||||||
*/
|
*/
|
||||||
export function getConfigFilePath(serverUrlHash: string, filename: string): string {
|
export function getConfigFilePath(
|
||||||
const configDir = getConfigDir()
|
serverUrlHash: string,
|
||||||
return path.join(configDir, `${serverUrlHash}_${filename}`)
|
filename: string,
|
||||||
|
): string {
|
||||||
|
const configDir = getConfigDir();
|
||||||
|
return path.join(configDir, `${serverUrlHash}_${filename}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -115,14 +133,17 @@ export function getConfigFilePath(serverUrlHash: string, filename: string): stri
|
||||||
* @param serverUrlHash The hash of the server URL
|
* @param serverUrlHash The hash of the server URL
|
||||||
* @param filename The name of the file to delete
|
* @param filename The name of the file to delete
|
||||||
*/
|
*/
|
||||||
export async function deleteConfigFile(serverUrlHash: string, filename: string): Promise<void> {
|
export async function deleteConfigFile(
|
||||||
|
serverUrlHash: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
const filePath = getConfigFilePath(serverUrlHash, filename);
|
||||||
await Deno.remove(filePath)
|
await Deno.remove(filePath);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// Ignore if file doesn't exist
|
// Ignore if file doesn't exist
|
||||||
if ((error as Deno.errors.NotFound).name !== 'NotFound') {
|
if ((_error as Deno.errors.NotFound).name !== "NotFound") {
|
||||||
log(`Error deleting ${filename}:`, error)
|
log(`Error deleting ${filename}:`, _error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -134,22 +155,25 @@ export async function deleteConfigFile(serverUrlHash: string, filename: string):
|
||||||
* @param schema The schema to validate against
|
* @param schema The schema to validate against
|
||||||
* @returns The parsed file content or undefined if the file doesn't exist
|
* @returns The parsed file content or undefined if the file doesn't exist
|
||||||
*/
|
*/
|
||||||
export async function readJsonFile<T>(serverUrlHash: string, filename: string, schema: any): Promise<T | undefined> {
|
export async function readJsonFile<T>(
|
||||||
|
serverUrlHash: string,
|
||||||
|
filename: string,
|
||||||
|
schema: { parseAsync: (data: unknown) => Promise<T | null> | T | null },
|
||||||
|
): Promise<T | undefined> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir();
|
||||||
|
|
||||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
const filePath = getConfigFilePath(serverUrlHash, filename);
|
||||||
const content = await Deno.readTextFile(filePath)
|
const content = await Deno.readTextFile(filePath);
|
||||||
const result = await schema.parseAsync(JSON.parse(content))
|
const result = await schema.parseAsync(JSON.parse(content));
|
||||||
// console.log({ filename: result })
|
return result ?? undefined;
|
||||||
return result
|
} catch (_error) {
|
||||||
} catch (error) {
|
if (_error instanceof Deno.errors.NotFound) {
|
||||||
if (error instanceof Deno.errors.NotFound) {
|
|
||||||
// console.log(`File ${filename} does not exist`)
|
// console.log(`File ${filename} does not exist`)
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
}
|
||||||
log(`Error reading ${filename}:`, error)
|
log(`Error reading ${filename}:`, _error);
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,14 +183,18 @@ export async function readJsonFile<T>(serverUrlHash: string, filename: string, s
|
||||||
* @param filename The name of the file to write
|
* @param filename The name of the file to write
|
||||||
* @param data The data to write
|
* @param data The data to write
|
||||||
*/
|
*/
|
||||||
export async function writeJsonFile(serverUrlHash: string, filename: string, data: any): Promise<void> {
|
export async function writeJsonFile(
|
||||||
|
serverUrlHash: string,
|
||||||
|
filename: string,
|
||||||
|
data: unknown,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir();
|
||||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
const filePath = getConfigFilePath(serverUrlHash, filename);
|
||||||
await Deno.writeTextFile(filePath, JSON.stringify(data, null, 2))
|
await Deno.writeTextFile(filePath, JSON.stringify(data, null, 2));
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
log(`Error writing ${filename}:`, error)
|
log(`Error writing ${filename}:`, _error);
|
||||||
throw error
|
throw _error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -177,13 +205,17 @@ export async function writeJsonFile(serverUrlHash: string, filename: string, dat
|
||||||
* @param errorMessage Optional custom error message
|
* @param errorMessage Optional custom error message
|
||||||
* @returns The file content as a string
|
* @returns The file content as a string
|
||||||
*/
|
*/
|
||||||
export async function readTextFile(serverUrlHash: string, filename: string, errorMessage?: string): Promise<string> {
|
export async function readTextFile(
|
||||||
|
serverUrlHash: string,
|
||||||
|
filename: string,
|
||||||
|
errorMessage?: string,
|
||||||
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir();
|
||||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
const filePath = getConfigFilePath(serverUrlHash, filename);
|
||||||
return await Deno.readTextFile(filePath)
|
return await Deno.readTextFile(filePath);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
throw new Error(errorMessage || `Error reading ${filename}`)
|
throw new Error(errorMessage || `Error reading ${filename}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -193,13 +225,17 @@ export async function readTextFile(serverUrlHash: string, filename: string, erro
|
||||||
* @param filename The name of the file to write
|
* @param filename The name of the file to write
|
||||||
* @param text The text to write
|
* @param text The text to write
|
||||||
*/
|
*/
|
||||||
export async function writeTextFile(serverUrlHash: string, filename: string, text: string): Promise<void> {
|
export async function writeTextFile(
|
||||||
|
serverUrlHash: string,
|
||||||
|
filename: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir();
|
||||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
const filePath = getConfigFilePath(serverUrlHash, filename);
|
||||||
await Deno.writeTextFile(filePath, text)
|
await Deno.writeTextFile(filePath, text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`Error writing ${filename}:`, error)
|
log(`Error writing ${filename}:`, error);
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,84 +1,108 @@
|
||||||
import open from 'npm:open'
|
import open from "npm:open";
|
||||||
import { OAuthClientProvider } from 'npm:@modelcontextprotocol/sdk/client/auth.js'
|
import type { OAuthClientProvider } from "npm:@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
import "npm:@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import {
|
import {
|
||||||
|
OAuthClientInformationSchema,
|
||||||
|
OAuthTokensSchema,
|
||||||
|
} from "npm:@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import type {
|
||||||
OAuthClientInformation,
|
OAuthClientInformation,
|
||||||
OAuthClientInformationFull,
|
OAuthClientInformationFull,
|
||||||
OAuthClientInformationSchema,
|
|
||||||
OAuthTokens,
|
OAuthTokens,
|
||||||
OAuthTokensSchema,
|
} from "npm:@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
} from 'npm:@modelcontextprotocol/sdk/shared/auth.js'
|
import type { OAuthProviderOptions } from "./types.ts";
|
||||||
import type { OAuthProviderOptions } from './types.ts'
|
import {
|
||||||
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile } from './mcp-auth-config.ts'
|
readJsonFile,
|
||||||
import { getServerUrlHash, log, MCP_REMOTE_VERSION } from './utils.ts'
|
readTextFile,
|
||||||
|
writeJsonFile,
|
||||||
|
writeTextFile,
|
||||||
|
} from "./mcp-auth-config.ts";
|
||||||
|
import { getServerUrlHash, log, MCP_REMOTE_VERSION } from "./utils.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implements the OAuthClientProvider interface for Node.js environments.
|
* Implements the OAuthClientProvider interface for Node.js environments.
|
||||||
* Handles OAuth flow and token storage for MCP clients.
|
* Handles OAuth flow and token storage for MCP clients.
|
||||||
*/
|
*/
|
||||||
export class NodeOAuthClientProvider implements OAuthClientProvider {
|
export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
private serverUrlHash: string
|
private serverUrlHash: string;
|
||||||
private callbackPath: string
|
private callbackPath: string;
|
||||||
private clientName: string
|
private clientName: string;
|
||||||
private clientUri: string
|
private clientUri: string;
|
||||||
private softwareId: string
|
private softwareId: string;
|
||||||
private softwareVersion: string
|
private softwareVersion: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new NodeOAuthClientProvider
|
* Creates a new NodeOAuthClientProvider
|
||||||
* @param options Configuration options for the provider
|
* @param options Configuration options for the provider
|
||||||
*/
|
*/
|
||||||
constructor(readonly options: OAuthProviderOptions) {
|
constructor(readonly options: OAuthProviderOptions) {
|
||||||
this.serverUrlHash = getServerUrlHash(options.serverUrl)
|
this.serverUrlHash = getServerUrlHash(options.serverUrl);
|
||||||
this.callbackPath = options.callbackPath || '/oauth/callback'
|
this.callbackPath = options.callbackPath || "/oauth/callback";
|
||||||
this.clientName = options.clientName || 'MCP CLI Client'
|
this.clientName = options.clientName || "MCP CLI Client";
|
||||||
this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli'
|
this.clientUri = options.clientUri ||
|
||||||
this.softwareId = options.softwareId || '2e6dc280-f3c3-4e01-99a7-8181dbd1d23d'
|
"https://github.com/modelcontextprotocol/mcp-cli";
|
||||||
this.softwareVersion = options.softwareVersion || MCP_REMOTE_VERSION
|
this.softwareId = options.softwareId ||
|
||||||
|
"2e6dc280-f3c3-4e01-99a7-8181dbd1d23d";
|
||||||
|
this.softwareVersion = options.softwareVersion || MCP_REMOTE_VERSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
get redirectUrl(): string {
|
get redirectUrl(): string {
|
||||||
return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}`
|
return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
get clientMetadata() {
|
get clientMetadata() {
|
||||||
return {
|
return {
|
||||||
redirect_uris: [this.redirectUrl],
|
redirect_uris: [this.redirectUrl],
|
||||||
token_endpoint_auth_method: 'none',
|
token_endpoint_auth_method: "none",
|
||||||
grant_types: ['authorization_code', 'refresh_token'],
|
grant_types: ["authorization_code", "refresh_token"],
|
||||||
response_types: ['code'],
|
response_types: ["code"],
|
||||||
client_name: this.clientName,
|
client_name: this.clientName,
|
||||||
client_uri: this.clientUri,
|
client_uri: this.clientUri,
|
||||||
software_id: this.softwareId,
|
software_id: this.softwareId,
|
||||||
software_version: this.softwareVersion,
|
software_version: this.softwareVersion,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the client information if it exists
|
* Gets the client information if it exists
|
||||||
* @returns The client information or undefined
|
* @returns The client information or undefined
|
||||||
*/
|
*/
|
||||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||||
// log('Reading client info')
|
// log('Reading client info')
|
||||||
return readJsonFile<OAuthClientInformation>(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema)
|
return readJsonFile<OAuthClientInformation>(
|
||||||
|
this.serverUrlHash,
|
||||||
|
"client_info.json",
|
||||||
|
OAuthClientInformationSchema,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves client information
|
* Saves client information
|
||||||
* @param clientInformation The client information to save
|
* @param clientInformation The client information to save
|
||||||
*/
|
*/
|
||||||
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
|
async saveClientInformation(
|
||||||
|
clientInformation: OAuthClientInformationFull,
|
||||||
|
): Promise<void> {
|
||||||
// log('Saving client info')
|
// log('Saving client info')
|
||||||
await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation)
|
await writeJsonFile(
|
||||||
|
this.serverUrlHash,
|
||||||
|
"client_info.json",
|
||||||
|
clientInformation,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the OAuth tokens if they exist
|
* Gets the OAuth tokens if they exist
|
||||||
* @returns The OAuth tokens or undefined
|
* @returns The OAuth tokens or undefined
|
||||||
*/
|
*/
|
||||||
async tokens(): Promise<OAuthTokens | undefined> {
|
tokens(): Promise<OAuthTokens | undefined> {
|
||||||
// log('Reading tokens')
|
// log('Reading tokens')
|
||||||
// console.log(new Error().stack)
|
// console.log(new Error().stack)
|
||||||
return readJsonFile<OAuthTokens>(this.serverUrlHash, 'tokens.json', OAuthTokensSchema)
|
return readJsonFile<OAuthTokens>(
|
||||||
|
this.serverUrlHash,
|
||||||
|
"tokens.json",
|
||||||
|
OAuthTokensSchema,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -87,7 +111,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
*/
|
*/
|
||||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
// log('Saving tokens')
|
// log('Saving tokens')
|
||||||
await writeJsonFile(this.serverUrlHash, 'tokens.json', tokens)
|
await writeJsonFile(this.serverUrlHash, "tokens.json", tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -95,12 +119,16 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
* @param authorizationUrl The URL to redirect to
|
* @param authorizationUrl The URL to redirect to
|
||||||
*/
|
*/
|
||||||
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||||
log(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`)
|
log(
|
||||||
|
`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await open(authorizationUrl.toString())
|
await open(authorizationUrl.toString());
|
||||||
log('Browser opened automatically.')
|
log("Browser opened automatically.");
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
log('Could not open browser automatically. Please copy and paste the URL above into your browser.')
|
log(
|
||||||
|
"Could not open browser automatically. Please copy and paste the URL above into your browser.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,7 +138,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
*/
|
*/
|
||||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||||
// log('Saving code verifier')
|
// log('Saving code verifier')
|
||||||
await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier)
|
await writeTextFile(this.serverUrlHash, "code_verifier.txt", codeVerifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -119,6 +147,10 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
*/
|
*/
|
||||||
async codeVerifier(): Promise<string> {
|
async codeVerifier(): Promise<string> {
|
||||||
// log('Reading code verifier')
|
// log('Reading code verifier')
|
||||||
return await readTextFile(this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session')
|
return await readTextFile(
|
||||||
|
this.serverUrlHash,
|
||||||
|
"code_verifier.txt",
|
||||||
|
"No code verifier saved for session",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
import type { EventEmitter } from 'node:events'
|
import type { EventEmitter } from "node:events";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating an OAuth client provider
|
* Options for creating an OAuth client provider
|
||||||
*/
|
*/
|
||||||
export interface OAuthProviderOptions {
|
export interface OAuthProviderOptions {
|
||||||
/** Server URL to connect to */
|
/** Server URL to connect to */
|
||||||
serverUrl: string
|
serverUrl: string;
|
||||||
/** Port for the OAuth callback server */
|
/** Port for the OAuth callback server */
|
||||||
callbackPort: number
|
callbackPort: number;
|
||||||
/** Path for the OAuth callback endpoint */
|
/** Path for the OAuth callback endpoint */
|
||||||
callbackPath?: string
|
callbackPath?: string;
|
||||||
/** Directory to store OAuth credentials */
|
/** Directory to store OAuth credentials */
|
||||||
configDir?: string
|
configDir?: string;
|
||||||
/** Client name to use for OAuth registration */
|
/** Client name to use for OAuth registration */
|
||||||
clientName?: string
|
clientName?: string;
|
||||||
/** Client URI to use for OAuth registration */
|
/** Client URI to use for OAuth registration */
|
||||||
clientUri?: string
|
clientUri?: string;
|
||||||
/** Software ID to use for OAuth registration */
|
/** Software ID to use for OAuth registration */
|
||||||
softwareId?: string
|
softwareId?: string;
|
||||||
/** Software version to use for OAuth registration */
|
/** Software version to use for OAuth registration */
|
||||||
softwareVersion?: string
|
softwareVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -27,9 +27,9 @@ export interface OAuthProviderOptions {
|
||||||
*/
|
*/
|
||||||
export interface OAuthCallbackServerOptions {
|
export interface OAuthCallbackServerOptions {
|
||||||
/** Port for the callback server */
|
/** Port for the callback server */
|
||||||
port: number
|
port: number;
|
||||||
/** Path for the callback endpoint */
|
/** Path for the callback endpoint */
|
||||||
path: string
|
path: string;
|
||||||
/** Event emitter to signal when auth code is received */
|
/** Event emitter to signal when auth code is received */
|
||||||
events: EventEmitter
|
events: EventEmitter;
|
||||||
}
|
}
|
||||||
|
|
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 {
|
||||||
import { SSEClientTransport } from 'npm:@modelcontextprotocol/sdk/client/sse.js'
|
type OAuthClientProvider,
|
||||||
import type { Transport } from 'npm:@modelcontextprotocol/sdk/shared/transport.js'
|
UnauthorizedError,
|
||||||
import type { OAuthCallbackServerOptions } from './types.ts'
|
} from "npm:@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import express from 'npm:express'
|
import { SSEClientTransport } from "npm:@modelcontextprotocol/sdk/client/sse.js";
|
||||||
import net from 'node:net'
|
import type { Transport } from "npm:@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
import crypto from 'node:crypto'
|
import type { OAuthCallbackServerOptions } from "./types.ts";
|
||||||
|
import express from "npm:express";
|
||||||
|
import net from "node:net";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
// Package version from deno.json (set a constant for now)
|
// Package version from deno.json (set a constant for now)
|
||||||
export const MCP_REMOTE_VERSION = '1.0.0' // TODO: Find better way to get version in Deno
|
export const MCP_REMOTE_VERSION = "1.0.0"; // TODO: Find better way to get version in Deno
|
||||||
|
|
||||||
const pid = Deno.pid
|
const pid = Deno.pid;
|
||||||
export function log(str: string, ...rest: unknown[]) {
|
export function log(str: string, ...rest: unknown[]) {
|
||||||
// Using stderr so that it doesn't interfere with stdout
|
// Using stderr so that it doesn't interfere with stdout
|
||||||
console.error(`[${pid}] ${str}`, ...rest)
|
console.error(`[${pid}] ${str}`, ...rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a bidirectional proxy between two transports
|
* Creates a bidirectional proxy between two transports
|
||||||
* @param params The transport connections to proxy between
|
* @param params The transport connections to proxy between
|
||||||
*/
|
*/
|
||||||
export function mcpProxy({ transportToClient, transportToServer }: { transportToClient: Transport; transportToServer: Transport }) {
|
export function mcpProxy(
|
||||||
let transportToClientClosed = false
|
{ transportToClient, transportToServer }: {
|
||||||
let transportToServerClosed = false
|
transportToClient: Transport;
|
||||||
|
transportToServer: Transport;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let transportToClientClosed = false;
|
||||||
|
let transportToServerClosed = false;
|
||||||
|
|
||||||
transportToClient.onmessage = (message) => {
|
transportToClient.onmessage = (message) => {
|
||||||
// @ts-expect-error TODO
|
// @ts-expect-error TODO
|
||||||
log('[Local→Remote]', message.method || message.id)
|
log("[Local→Remote]", message.method || message.id);
|
||||||
transportToServer.send(message).catch(onServerError)
|
transportToServer.send(message).catch(onServerError);
|
||||||
}
|
};
|
||||||
|
|
||||||
transportToServer.onmessage = (message) => {
|
transportToServer.onmessage = (message) => {
|
||||||
// @ts-expect-error TODO: fix this type
|
// @ts-expect-error TODO: fix this type
|
||||||
log('[Remote→Local]', message.method || message.id)
|
log("[Remote→Local]", message.method || message.id);
|
||||||
transportToClient.send(message).catch(onClientError)
|
transportToClient.send(message).catch(onClientError);
|
||||||
}
|
};
|
||||||
|
|
||||||
transportToClient.onclose = () => {
|
transportToClient.onclose = () => {
|
||||||
if (transportToServerClosed) {
|
if (transportToServerClosed) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
transportToClientClosed = true
|
transportToClientClosed = true;
|
||||||
transportToServer.close().catch(onServerError)
|
transportToServer.close().catch(onServerError);
|
||||||
}
|
};
|
||||||
|
|
||||||
transportToServer.onclose = () => {
|
transportToServer.onclose = () => {
|
||||||
if (transportToClientClosed) {
|
if (transportToClientClosed) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
transportToServerClosed = true
|
transportToServerClosed = true;
|
||||||
transportToClient.close().catch(onClientError)
|
transportToClient.close().catch(onClientError);
|
||||||
}
|
};
|
||||||
|
|
||||||
transportToClient.onerror = onClientError
|
transportToClient.onerror = onClientError;
|
||||||
transportToServer.onerror = onServerError
|
transportToServer.onerror = onServerError;
|
||||||
|
|
||||||
function onClientError(error: Error) {
|
function onClientError(error: Error) {
|
||||||
log('Error from local client:', error)
|
log("Error from local client:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onServerError(error: Error) {
|
function onServerError(error: Error) {
|
||||||
log('Error from remote server:', error)
|
log("Error from remote server:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,8 +88,8 @@ export async function connectToRemoteServer(
|
||||||
waitForAuthCode: () => Promise<string>,
|
waitForAuthCode: () => Promise<string>,
|
||||||
skipBrowserAuth = false,
|
skipBrowserAuth = false,
|
||||||
): Promise<SSEClientTransport> {
|
): Promise<SSEClientTransport> {
|
||||||
log(`[${pid}] Connecting to remote server: ${serverUrl}`)
|
log(`[${pid}] Connecting to remote server: ${serverUrl}`);
|
||||||
const url = new URL(serverUrl)
|
const url = new URL(serverUrl);
|
||||||
|
|
||||||
// Create transport with eventSourceInit to pass Authorization header if present
|
// Create transport with eventSourceInit to pass Authorization header if present
|
||||||
const eventSourceInit = {
|
const eventSourceInit = {
|
||||||
|
@ -92,7 +100,9 @@ export async function connectToRemoteServer(
|
||||||
headers: {
|
headers: {
|
||||||
...(init?.headers as Record<string, string> | undefined),
|
...(init?.headers as Record<string, string> | undefined),
|
||||||
...headers,
|
...headers,
|
||||||
...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}),
|
...(tokens?.access_token
|
||||||
|
? { Authorization: `Bearer ${tokens.access_token}` }
|
||||||
|
: {}),
|
||||||
Accept: "text/event-stream",
|
Accept: "text/event-stream",
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
})
|
})
|
||||||
|
@ -104,39 +114,47 @@ export async function connectToRemoteServer(
|
||||||
authProvider,
|
authProvider,
|
||||||
requestInit: { headers },
|
requestInit: { headers },
|
||||||
eventSourceInit,
|
eventSourceInit,
|
||||||
})
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.start()
|
await transport.start();
|
||||||
log('Connected to remote server')
|
log("Connected to remote server");
|
||||||
return transport
|
return transport;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
if (
|
||||||
|
error instanceof UnauthorizedError ||
|
||||||
|
(error instanceof Error && error.message.includes("Unauthorized"))
|
||||||
|
) {
|
||||||
if (skipBrowserAuth) {
|
if (skipBrowserAuth) {
|
||||||
log('Authentication required but skipping browser auth - using shared auth')
|
log(
|
||||||
|
"Authentication required but skipping browser auth - using shared auth",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
log('Authentication required. Waiting for authorization...')
|
log("Authentication required. Waiting for authorization...");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the authorization code from the callback
|
// Wait for the authorization code from the callback
|
||||||
const code = await waitForAuthCode()
|
const code = await waitForAuthCode();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log('Completing authorization...')
|
log("Completing authorization...");
|
||||||
await transport.finishAuth(code)
|
await transport.finishAuth(code);
|
||||||
|
|
||||||
// Create a new transport after auth
|
// Create a new transport after auth
|
||||||
const newTransport = new SSEClientTransport(url, { authProvider, requestInit: { headers } })
|
const newTransport = new SSEClientTransport(url, {
|
||||||
await newTransport.start()
|
authProvider,
|
||||||
log('Connected to remote server after authentication')
|
requestInit: { headers },
|
||||||
return newTransport
|
});
|
||||||
|
await newTransport.start();
|
||||||
|
log("Connected to remote server after authentication");
|
||||||
|
return newTransport;
|
||||||
} catch (authError) {
|
} catch (authError) {
|
||||||
log('Authorization error:', authError)
|
log("Authorization error:", authError);
|
||||||
throw authError
|
throw authError;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log('Connection error:', error)
|
log("Connection error:", error);
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -146,92 +164,96 @@ export async function connectToRemoteServer(
|
||||||
* @param options The server options
|
* @param options The server options
|
||||||
* @returns An object with the server, authCode, and waitForAuthCode function
|
* @returns An object with the server, authCode, and waitForAuthCode function
|
||||||
*/
|
*/
|
||||||
export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServerOptions) {
|
export function setupOAuthCallbackServerWithLongPoll(
|
||||||
let authCode: string | null = null
|
options: OAuthCallbackServerOptions,
|
||||||
const app = express()
|
) {
|
||||||
|
let authCode: string | null = null;
|
||||||
|
const app = express();
|
||||||
|
|
||||||
// Create a promise to track when auth is completed
|
// Create a promise to track when auth is completed
|
||||||
let authCompletedResolve: (code: string) => void
|
let authCompletedResolve: (code: string) => void;
|
||||||
const authCompletedPromise = new Promise<string>((resolve) => {
|
const authCompletedPromise = new Promise<string>((resolve) => {
|
||||||
authCompletedResolve = resolve
|
authCompletedResolve = resolve;
|
||||||
})
|
});
|
||||||
|
|
||||||
// Long-polling endpoint
|
// Long-polling endpoint
|
||||||
app.get('/wait-for-auth', (req, res) => {
|
app.get("/wait-for-auth", (req, res) => {
|
||||||
if (authCode) {
|
if (authCode) {
|
||||||
// Auth already completed - just return 200 without the actual code
|
// Auth already completed - just return 200 without the actual code
|
||||||
// Secondary instances will read tokens from disk
|
// Secondary instances will read tokens from disk
|
||||||
log('Auth already completed, returning 200')
|
log("Auth already completed, returning 200");
|
||||||
res.status(200).send('Authentication completed')
|
res.status(200).send("Authentication completed");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.query.poll === 'false') {
|
if (req.query.poll === "false") {
|
||||||
log('Client requested no long poll, responding with 202')
|
log("Client requested no long poll, responding with 202");
|
||||||
res.status(202).send('Authentication in progress')
|
res.status(202).send("Authentication in progress");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Long poll - wait for up to 30 seconds
|
// Long poll - wait for up to 30 seconds
|
||||||
const longPollTimeout = setTimeout(() => {
|
const longPollTimeout = setTimeout(() => {
|
||||||
log('Long poll timeout reached, responding with 202')
|
log("Long poll timeout reached, responding with 202");
|
||||||
res.status(202).send('Authentication in progress')
|
res.status(202).send("Authentication in progress");
|
||||||
}, 30000)
|
}, 30000);
|
||||||
|
|
||||||
// If auth completes while we're waiting, send the response immediately
|
// If auth completes while we're waiting, send the response immediately
|
||||||
authCompletedPromise
|
authCompletedPromise
|
||||||
.then(() => {
|
.then(() => {
|
||||||
clearTimeout(longPollTimeout)
|
clearTimeout(longPollTimeout);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
log('Auth completed during long poll, responding with 200')
|
log("Auth completed during long poll, responding with 200");
|
||||||
res.status(200).send('Authentication completed')
|
res.status(200).send("Authentication completed");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
clearTimeout(longPollTimeout)
|
clearTimeout(longPollTimeout);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
log('Auth failed during long poll, responding with 500')
|
log("Auth failed during long poll, responding with 500");
|
||||||
res.status(500).send('Authentication failed')
|
res.status(500).send("Authentication failed");
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// OAuth callback endpoint
|
// OAuth callback endpoint
|
||||||
app.get(options.path, (req, res) => {
|
app.get(options.path, (req, res) => {
|
||||||
const code = req.query.code as string | undefined
|
const code = req.query.code as string | undefined;
|
||||||
if (!code) {
|
if (!code) {
|
||||||
res.status(400).send('Error: No authorization code received')
|
res.status(400).send("Error: No authorization code received");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
authCode = code
|
authCode = code;
|
||||||
log('Auth code received, resolving promise')
|
log("Auth code received, resolving promise");
|
||||||
authCompletedResolve(code)
|
authCompletedResolve(code);
|
||||||
|
|
||||||
res.send('Authorization successful! You may close this window and return to the CLI.')
|
res.send(
|
||||||
|
"Authorization successful! You may close this window and return to the CLI.",
|
||||||
|
);
|
||||||
|
|
||||||
// Notify main flow that auth code is available
|
// Notify main flow that auth code is available
|
||||||
options.events.emit('auth-code-received', code)
|
options.events.emit("auth-code-received", code);
|
||||||
})
|
});
|
||||||
|
|
||||||
const server = app.listen(options.port, () => {
|
const server = app.listen(options.port, () => {
|
||||||
log(`OAuth callback server running at http://127.0.0.1:${options.port}`)
|
log(`OAuth callback server running at http://127.0.0.1:${options.port}`);
|
||||||
})
|
});
|
||||||
|
|
||||||
const waitForAuthCode = (): Promise<string> => {
|
const waitForAuthCode = (): Promise<string> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (authCode) {
|
if (authCode) {
|
||||||
resolve(authCode)
|
resolve(authCode);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
options.events.once('auth-code-received', (code) => {
|
options.events.once("auth-code-received", (code) => {
|
||||||
resolve(code)
|
resolve(code);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return { server, authCode, waitForAuthCode, authCompletedPromise }
|
return { server, authCode, waitForAuthCode, authCompletedPromise };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -240,8 +262,9 @@ export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServe
|
||||||
* @returns An object with the server, authCode, and waitForAuthCode function
|
* @returns An object with the server, authCode, and waitForAuthCode function
|
||||||
*/
|
*/
|
||||||
export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
|
export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
|
||||||
const { server, authCode, waitForAuthCode } = setupOAuthCallbackServerWithLongPoll(options)
|
const { server, authCode, waitForAuthCode } =
|
||||||
return { server, authCode, waitForAuthCode }
|
setupOAuthCallbackServerWithLongPoll(options);
|
||||||
|
return { server, authCode, waitForAuthCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -249,29 +272,31 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
|
||||||
* @param preferredPort Optional preferred port to try first
|
* @param preferredPort Optional preferred port to try first
|
||||||
* @returns A promise that resolves to an available port number
|
* @returns A promise that resolves to an available port number
|
||||||
*/
|
*/
|
||||||
export async function findAvailablePort(preferredPort?: number): Promise<number> {
|
export function findAvailablePort(
|
||||||
|
preferredPort?: number,
|
||||||
|
): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer()
|
const server = net.createServer();
|
||||||
|
|
||||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
if (err.code === 'EADDRINUSE') {
|
if (err.code === "EADDRINUSE") {
|
||||||
// If preferred port is in use, get a random port
|
// If preferred port is in use, get a random port
|
||||||
server.listen(0)
|
server.listen(0);
|
||||||
} else {
|
} else {
|
||||||
reject(err)
|
reject(err);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
server.on('listening', () => {
|
server.on("listening", () => {
|
||||||
const { port } = server.address() as net.AddressInfo
|
const { port } = server.address() as net.AddressInfo;
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
resolve(port)
|
resolve(port);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// Try preferred port first, or get a random port
|
// Try preferred port first, or get a random port
|
||||||
server.listen(preferredPort || 0)
|
server.listen(preferredPort || 0);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -281,75 +306,85 @@ export async function findAvailablePort(preferredPort?: number): Promise<number>
|
||||||
* @param usage Usage message to show on error
|
* @param usage Usage message to show on error
|
||||||
* @returns A promise that resolves to an object with parsed serverUrl, callbackPort and headers
|
* @returns A promise that resolves to an object with parsed serverUrl, callbackPort and headers
|
||||||
*/
|
*/
|
||||||
export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) {
|
export async function parseCommandLineArgs(
|
||||||
|
args: string[],
|
||||||
|
defaultPort: number,
|
||||||
|
usage: string,
|
||||||
|
) {
|
||||||
// Check for help flag
|
// Check for help flag
|
||||||
if (args.includes('--help') || args.includes('-h')) {
|
if (args.includes("--help") || args.includes("-h")) {
|
||||||
log(usage)
|
log(usage);
|
||||||
Deno.exit(0)
|
Deno.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process headers
|
// Process headers
|
||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {};
|
||||||
args.forEach((arg, i) => {
|
args.forEach((arg, i) => {
|
||||||
if (arg === '--header' && i < args.length - 1) {
|
if (arg === "--header" && i < args.length - 1) {
|
||||||
const value = args[i + 1]
|
const value = args[i + 1];
|
||||||
const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/)
|
const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
headers[match[1]] = match[2]
|
headers[match[1]] = match[2];
|
||||||
} else {
|
} else {
|
||||||
log(`Warning: ignoring invalid header argument: ${value}`)
|
log(`Warning: ignoring invalid header argument: ${value}`);
|
||||||
}
|
}
|
||||||
args.splice(i, 2)
|
args.splice(i, 2);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const serverUrl = args[0]
|
const serverUrl = args[0];
|
||||||
const specifiedPort = args[1] ? Number.parseInt(args[1], 10) : undefined
|
const specifiedPort = args[1] ? Number.parseInt(args[1], 10) : undefined;
|
||||||
const allowHttp = args.includes('--allow-http')
|
const allowHttp = args.includes("--allow-http");
|
||||||
|
|
||||||
if (!serverUrl) {
|
if (!serverUrl) {
|
||||||
log(usage)
|
log(usage);
|
||||||
Deno.exit(1)
|
Deno.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(serverUrl)
|
const url = new URL(serverUrl);
|
||||||
const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:'
|
const isLocalhost =
|
||||||
|
(url.hostname === "localhost" || url.hostname === "127.0.0.1") &&
|
||||||
|
url.protocol === "http:";
|
||||||
|
|
||||||
if (!(url.protocol === 'https:' || isLocalhost || allowHttp)) {
|
if (!(url.protocol === "https:" || isLocalhost || allowHttp)) {
|
||||||
log('Error: Non-HTTPS URLs are only allowed for localhost or when --allow-http flag is provided')
|
log(
|
||||||
log(usage)
|
"Error: Non-HTTPS URLs are only allowed for localhost or when --allow-http flag is provided",
|
||||||
Deno.exit(1)
|
);
|
||||||
|
log(usage);
|
||||||
|
Deno.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the specified port, or find an available one
|
// Use the specified port, or find an available one
|
||||||
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort))
|
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort));
|
||||||
|
|
||||||
if (specifiedPort) {
|
if (specifiedPort) {
|
||||||
log(`Using specified callback port: ${callbackPort}`)
|
log(`Using specified callback port: ${callbackPort}`);
|
||||||
} else {
|
} else {
|
||||||
log(`Using automatically selected callback port: ${callbackPort}`)
|
log(`Using automatically selected callback port: ${callbackPort}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(headers).length > 0) {
|
if (Object.keys(headers).length > 0) {
|
||||||
log(`Using custom headers: ${JSON.stringify(headers)}`)
|
log(`Using custom headers: ${JSON.stringify(headers)}`);
|
||||||
}
|
}
|
||||||
// Replace environment variables in headers
|
// Replace environment variables in headers
|
||||||
// example `Authorization: Bearer ${TOKEN}` will read process.env.TOKEN
|
// example `Authorization: Bearer ${TOKEN}` will read process.env.TOKEN
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
headers[key] = value.replace(/\$\{([^}]+)}/g, (match, envVarName) => {
|
headers[key] = value.replace(/\$\{([^}]+)}/g, (match, envVarName) => {
|
||||||
const envVarValue = Deno.env.get(envVarName)
|
const envVarValue = Deno.env.get(envVarName);
|
||||||
|
|
||||||
if (envVarValue !== undefined) {
|
if (envVarValue !== undefined) {
|
||||||
log(`Replacing ${match} with environment value in header '${key}'`)
|
log(`Replacing ${match} with environment value in header '${key}'`);
|
||||||
return envVarValue
|
return envVarValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`Warning: Environment variable '${envVarName}' not found for header '${key}'.`)
|
log(
|
||||||
return ''
|
`Warning: Environment variable '${envVarName}' not found for header '${key}'.`,
|
||||||
})
|
);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { serverUrl, callbackPort, headers }
|
return { serverUrl, callbackPort, headers };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -358,21 +393,21 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
|
||||||
*/
|
*/
|
||||||
export function setupSignalHandlers(cleanup: () => Promise<void>) {
|
export function setupSignalHandlers(cleanup: () => Promise<void>) {
|
||||||
Deno.addSignalListener("SIGINT", async () => {
|
Deno.addSignalListener("SIGINT", async () => {
|
||||||
log('\nShutting down...')
|
log("\nShutting down...");
|
||||||
await cleanup()
|
await cleanup();
|
||||||
Deno.exit(0)
|
Deno.exit(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
// For SIGTERM
|
// For SIGTERM
|
||||||
try {
|
try {
|
||||||
Deno.addSignalListener("SIGTERM", async () => {
|
Deno.addSignalListener("SIGTERM", async () => {
|
||||||
log('\nReceived SIGTERM. Shutting down...')
|
log("\nReceived SIGTERM. Shutting down...");
|
||||||
await cleanup()
|
await cleanup();
|
||||||
Deno.exit(0)
|
Deno.exit(0);
|
||||||
})
|
});
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// SIGTERM might not be available on all platforms
|
// SIGTERM might not be available on all platforms
|
||||||
log('SIGTERM handler not available on this platform')
|
log("SIGTERM handler not available on this platform");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -382,5 +417,5 @@ export function setupSignalHandlers(cleanup: () => Promise<void>) {
|
||||||
* @returns The hashed server URL
|
* @returns The hashed server URL
|
||||||
*/
|
*/
|
||||||
export function getServerUrlHash(serverUrl: string): string {
|
export function getServerUrlHash(serverUrl: string): string {
|
||||||
return crypto.createHash('md5').update(serverUrl).digest('hex')
|
return crypto.createHash("md5").update(serverUrl).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
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.
|
* If callback-port is not specified, an available port will be automatically selected.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from "node:events";
|
||||||
import { StdioServerTransport } from 'npm:@modelcontextprotocol/sdk/server/stdio.js'
|
import { StdioServerTransport } from "npm:@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash } from './lib/utils.ts'
|
import {
|
||||||
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider.ts'
|
connectToRemoteServer,
|
||||||
import { coordinateAuth } from './lib/coordination.ts'
|
getServerUrlHash,
|
||||||
|
log,
|
||||||
|
mcpProxy,
|
||||||
|
parseCommandLineArgs,
|
||||||
|
setupSignalHandlers,
|
||||||
|
} from "./lib/utils.ts";
|
||||||
|
import { NodeOAuthClientProvider } from "./lib/node-oauth-client-provider.ts";
|
||||||
|
import { coordinateAuth } from "./lib/coordination.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main function to run the proxy
|
* Main function to run the proxy
|
||||||
*/
|
*/
|
||||||
async function runProxy(serverUrl: string, callbackPort: number, headers: Record<string, string>) {
|
async function runProxy(
|
||||||
|
serverUrl: string,
|
||||||
|
callbackPort: number,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
) {
|
||||||
// Set up event emitter for auth flow
|
// Set up event emitter for auth flow
|
||||||
const events = new EventEmitter()
|
const events = new EventEmitter();
|
||||||
|
|
||||||
// Get the server URL hash for lockfile operations
|
// Get the server URL hash for lockfile operations
|
||||||
const serverUrlHash = getServerUrlHash(serverUrl)
|
const serverUrlHash = getServerUrlHash(serverUrl);
|
||||||
|
|
||||||
// Coordinate authentication with other instances
|
// Coordinate authentication with other instances
|
||||||
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events)
|
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(
|
||||||
|
serverUrlHash,
|
||||||
|
callbackPort,
|
||||||
|
events,
|
||||||
|
);
|
||||||
|
|
||||||
// Create the OAuth client provider
|
// Create the OAuth client provider
|
||||||
const authProvider = new NodeOAuthClientProvider({
|
const authProvider = new NodeOAuthClientProvider({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
callbackPort,
|
callbackPort,
|
||||||
clientName: 'MCP CLI Proxy',
|
clientName: "MCP CLI Proxy",
|
||||||
})
|
});
|
||||||
|
|
||||||
// If auth was completed by another instance, just log that we'll use the auth from disk
|
// If auth was completed by another instance, just log that we'll use the auth from disk
|
||||||
if (skipBrowserAuth) {
|
if (skipBrowserAuth) {
|
||||||
log('Authentication was completed by another instance - will use tokens from disk')
|
log(
|
||||||
|
"Authentication was completed by another instance - will use tokens from disk",
|
||||||
|
);
|
||||||
// TODO: remove, the callback is happening before the tokens are exchanged
|
// TODO: remove, the callback is happening before the tokens are exchanged
|
||||||
// so we're slightly too early
|
// so we're slightly too early
|
||||||
await new Promise((res) => setTimeout(res, 1_000))
|
await new Promise((res) => setTimeout(res, 1_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the STDIO transport for local connections
|
// Create the STDIO transport for local connections
|
||||||
const localTransport = new StdioServerTransport()
|
const localTransport = new StdioServerTransport();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Connect to remote server with authentication
|
// Connect to remote server with authentication
|
||||||
const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, headers, waitForAuthCode, skipBrowserAuth)
|
const remoteTransport = await connectToRemoteServer(
|
||||||
|
serverUrl,
|
||||||
|
authProvider,
|
||||||
|
headers,
|
||||||
|
waitForAuthCode,
|
||||||
|
skipBrowserAuth,
|
||||||
|
);
|
||||||
|
|
||||||
// Set up bidirectional proxy between local and remote transports
|
// Set up bidirectional proxy between local and remote transports
|
||||||
mcpProxy({
|
mcpProxy({
|
||||||
transportToClient: localTransport,
|
transportToClient: localTransport,
|
||||||
transportToServer: remoteTransport,
|
transportToServer: remoteTransport,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Start the local STDIO server
|
// Start the local STDIO server
|
||||||
await localTransport.start()
|
await localTransport.start();
|
||||||
log('Local STDIO server running')
|
log("Local STDIO server running");
|
||||||
log('Proxy established successfully between local STDIO and remote SSE')
|
log("Proxy established successfully between local STDIO and remote SSE");
|
||||||
log('Press Ctrl+C to exit')
|
log("Press Ctrl+C to exit");
|
||||||
|
|
||||||
// Setup cleanup handler
|
// Setup cleanup handler
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
await remoteTransport.close()
|
await remoteTransport.close();
|
||||||
await localTransport.close()
|
await localTransport.close();
|
||||||
server.close()
|
server.close();
|
||||||
}
|
};
|
||||||
setupSignalHandlers(cleanup)
|
setupSignalHandlers(cleanup);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('Fatal error:', error)
|
log("Fatal error:", error);
|
||||||
if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) {
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.includes("self-signed certificate in certificate chain")
|
||||||
|
) {
|
||||||
log(`You may be behind a VPN!
|
log(`You may be behind a VPN!
|
||||||
|
|
||||||
If you are behind a VPN, you can try setting the NODE_EXTRA_CA_CERTS environment variable to point
|
If you are behind a VPN, you can try setting the NODE_EXTRA_CA_CERTS environment variable to point
|
||||||
|
@ -92,19 +118,23 @@ to the CA certificate file. If using claude_desktop_config.json, this might look
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`)
|
`);
|
||||||
}
|
}
|
||||||
server.close()
|
server.close();
|
||||||
Deno.exit(1)
|
Deno.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse command-line arguments and run the proxy
|
// Parse command-line arguments and run the proxy
|
||||||
parseCommandLineArgs(Deno.args, 3334, 'Usage: deno run src/proxy.ts <https://server-url> [callback-port]')
|
parseCommandLineArgs(
|
||||||
|
Deno.args,
|
||||||
|
3334,
|
||||||
|
"Usage: deno run src/proxy.ts <https://server-url> [callback-port]",
|
||||||
|
)
|
||||||
.then(({ serverUrl, callbackPort, headers }) => {
|
.then(({ serverUrl, callbackPort, headers }) => {
|
||||||
return runProxy(serverUrl, callbackPort, headers)
|
return runProxy(serverUrl, callbackPort, headers);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
log('Fatal error:', error)
|
log("Fatal error:", error);
|
||||||
Deno.exit(1)
|
Deno.exit(1);
|
||||||
})
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue