From 2bbcaf79631a8bc4f12e4c899eb39129a32beef0 Mon Sep 17 00:00:00 2001 From: Minoru Mizutani Date: Tue, 29 Apr 2025 03:49:30 +0900 Subject: [PATCH] Refactor implementation to replace Express with native Deno HTTP server and add cross-platform URL opening functionality. Update implementation plan to reflect completed tasks for dependency management. --- implmentation_plan.md | 4 +- src/lib/deno-http-server.ts | 144 ++++++++++++++++++++++++++ src/lib/deno-open.ts | 70 +++++++++++++ src/lib/node-oauth-client-provider.ts | 2 +- src/lib/utils.ts | 30 +++--- 5 files changed, 232 insertions(+), 18 deletions(-) create mode 100644 src/lib/deno-http-server.ts create mode 100644 src/lib/deno-open.ts diff --git a/implmentation_plan.md b/implmentation_plan.md index 620eac7..7f77e45 100644 --- a/implmentation_plan.md +++ b/implmentation_plan.md @@ -35,8 +35,8 @@ Here is a plan to transform your Node.js CLI package into a Deno CLI project, fo - [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. + - [x] Evaluate replacing `npm:express` with a native Deno HTTP server solution (e.g., `Deno.serve` or from `std/http`). + - [x] 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. diff --git a/src/lib/deno-http-server.ts b/src/lib/deno-http-server.ts new file mode 100644 index 0000000..3ebb0c2 --- /dev/null +++ b/src/lib/deno-http-server.ts @@ -0,0 +1,144 @@ +import type { Server } from "node:http"; + +// Simple type definitions for our server +interface RequestLike { + query: Record; + path: string; +} + +/** + * A simple HTTP server using Deno's native HTTP server capabilities + * that mimics the Express API for our specific use case + */ +export class DenoHttpServer { + private server: Deno.HttpServer | null = null; + private routes: Map Promise | Response> = new Map(); + + /** + * Register a GET route handler + * @param path The path to handle + * @param handler The handler function + */ + get(path: string, handler: (req: RequestLike, res: ResponseBuilder) => void): void { + this.routes.set(path, async (request: Request) => { + const url = new URL(request.url); + const searchParams = url.searchParams; + const responseBuilder = new ResponseBuilder(); + + // Create a simple request object that mimics Express req + const query: Record = {}; + for (const [key, value] of searchParams) { + query[key] = value; + } + + const req: RequestLike = { + query, + path: url.pathname, + }; + + // Call the handler with our simplified req/res objects + handler(req, responseBuilder); + + // Wait for the response to be ready (in case of async operations) + return await responseBuilder.getResponse(); + }); + } + + /** + * Start the server listening on the specified port + * @param port The port to listen on + * @param callback Optional callback when server is ready + */ + listen(port: number, callback?: () => void): Server { + this.server = Deno.serve({ + port, + onListen: callback ? () => callback() : undefined, + handler: async (request: Request) => { + const url = new URL(request.url); + const path = url.pathname; + + // Find the route handler + const handler = this.routes.get(path); + if (handler) { + return await handler(request); + } + + // Route not found + return new Response("Not Found", { status: 404 }); + } + }); + + // Return a dummy server object that mimics Node's HTTP server + // This is needed to maintain API compatibility + return { + close: () => this.close(), + } as unknown as Server; + } + + /** + * Close the server + */ + close(): Promise { + if (this.server) { + return this.server.shutdown(); + } + return Promise.resolve(); + } +} + +/** + * Response builder class that mimics Express Response + */ +export class ResponseBuilder { + private statusCode = 200; + private body: string | null = null; + private responsePromise: Promise; + private resolveResponse!: (response: Response) => void; + headersSent = false; + + constructor() { + // Create a promise that will be resolved when the response is ready + this.responsePromise = new Promise((resolve) => { + this.resolveResponse = resolve; + }); + } + + /** + * Set the HTTP status code + * @param code HTTP status code + * @returns this instance for chaining + */ + status(code: number): ResponseBuilder { + this.statusCode = code; + return this; + } + + /** + * Send a response + * @param data The response data + */ + send(data: string): void { + if (this.headersSent) { + return; + } + this.headersSent = true; + this.body = data; + this.resolveResponse(new Response(this.body, { status: this.statusCode })); + } + + /** + * Get the response promise + * @returns Promise that resolves to the final Response + */ + getResponse(): Promise { + return this.responsePromise; + } +} + +/** + * Create and return a new HTTP server instance + * @returns A new HTTP server instance + */ +export default function createServer(): DenoHttpServer { + return new DenoHttpServer(); +} diff --git a/src/lib/deno-open.ts b/src/lib/deno-open.ts new file mode 100644 index 0000000..5212ad0 --- /dev/null +++ b/src/lib/deno-open.ts @@ -0,0 +1,70 @@ +/** + * Opens a URL in the default browser. + * This is a cross-platform implementation using Deno subprocess API + * @param url The URL to open + * @returns A promise that resolves when the command has been executed + */ +export default async function open(url: string): Promise { + let command: string[]; + const isWindows = Deno.build.os === "windows"; + const isMac = Deno.build.os === "darwin"; + const isLinux = Deno.build.os === "linux"; + + if (isWindows) { + command = ["cmd", "/c", "start", "", url]; + } else if (isMac) { + command = ["open", url]; + } else if (isLinux) { + // On Linux, try several common browser-opener commands + const linuxCommands = [ + ["xdg-open", url], + ["gnome-open", url], + ["kde-open", url], + ["wslview", url] // For Windows Subsystem for Linux + ]; + + // Try each command in order until one succeeds + for (const cmd of linuxCommands) { + try { + const process = new Deno.Command(cmd[0], { + args: cmd.slice(1), + stdout: "null", + stderr: "null" + }).spawn(); + + const status = await process.status; + + if (status.success) { + return; // Command succeeded, so exit the function + } + } catch { + // If this command fails, try the next one + } + } + + // If we get here, none of the commands worked + throw new Error("Could not open browser on Linux. Please open URL manually."); + } else { + throw new Error(`Unsupported platform: ${Deno.build.os}`); + } + + // For Windows and Mac, execute the chosen command + if (isWindows || isMac) { + try { + const process = new Deno.Command(command[0], { + args: command.slice(1), + stdout: "null", + stderr: "null" + }).spawn(); + + const status = await process.status; + + if (!status.success) { + throw new Error(`Failed to open ${url} in browser`); + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to open ${url} in browser: ${errorMessage}`); + } + } +} diff --git a/src/lib/node-oauth-client-provider.ts b/src/lib/node-oauth-client-provider.ts index be8117d..03db482 100644 --- a/src/lib/node-oauth-client-provider.ts +++ b/src/lib/node-oauth-client-provider.ts @@ -1,4 +1,3 @@ -import open from "npm:open"; import type { OAuthClientProvider } from "npm:@modelcontextprotocol/sdk/client/auth.js"; import "npm:@modelcontextprotocol/sdk/client/auth.js"; import { @@ -18,6 +17,7 @@ import { writeTextFile, } from "./mcp-auth-config.ts"; import { getServerUrlHash, log, MCP_REMOTE_VERSION } from "./utils.ts"; +import open from "./deno-open.ts"; /** * Implements the OAuthClientProvider interface for Node.js environments. diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4e5bdf1..d960503 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,9 +5,9 @@ import { 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 createServer from "./deno-http-server.ts"; // 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 @@ -160,7 +160,18 @@ export async function connectToRemoteServer( } /** - * Sets up an Express server to handle OAuth callbacks + * Sets up an HTTP server to handle OAuth callbacks + * @param options The server options + * @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 }; +} + +/** + * Sets up an HTTP server to handle OAuth callbacks * @param options The server options * @returns An object with the server, authCode, and waitForAuthCode function */ @@ -168,7 +179,7 @@ export function setupOAuthCallbackServerWithLongPoll( options: OAuthCallbackServerOptions, ) { let authCode: string | null = null; - const app = express(); + const app = createServer(); // Create a promise to track when auth is completed let authCompletedResolve: (code: string) => void; @@ -218,7 +229,7 @@ export function setupOAuthCallbackServerWithLongPoll( // OAuth callback endpoint app.get(options.path, (req, res) => { - const code = req.query.code as string | undefined; + const code = req.query.code; if (!code) { res.status(400).send("Error: No authorization code received"); return; @@ -256,17 +267,6 @@ export function setupOAuthCallbackServerWithLongPoll( return { server, authCode, waitForAuthCode, authCompletedPromise }; } -/** - * Sets up an Express server to handle OAuth callbacks - * @param options The server options - * @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 }; -} - /** * Finds an available port on the local machine * @param preferredPort Optional preferred port to try first