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

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

View file

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