Rewrite proxy.ts for Deno
This commit is contained in:
parent
61a1f4c3cc
commit
b0216017a2
8 changed files with 1494 additions and 76 deletions
|
@ -12,18 +12,18 @@ Here is a plan to transform your Node.js CLI package into a Deno CLI project, fo
|
|||
- [x] Review `compilerOptions`. Deno uses these, but ensure they align with Deno's defaults or project needs. Remove `"types": ["node"]` as Deno handles Node types via `node:` specifiers.
|
||||
- [x] Remove `"unstable": ["sloppy-imports"]` and plan to add explicit file extensions to imports.
|
||||
3. **Adapt Code in `src/`:**
|
||||
- [ ] **Imports:**
|
||||
- [ ] Prefix all Node.js built-in module imports with `node:` (e.g., `import { EventEmitter } from 'node:events';`).
|
||||
- [ ] Update imports for external npm packages to match the `npm:` specifiers defined in `deno.json` or directly use `npm:` specifiers in the import statement.
|
||||
- [ ] Append the `.ts` (or `.js` if applicable) extension to all relative file imports within the `src/` directory (e.g., `import { ... } from './lib/utils.ts';`).
|
||||
- [ ] **Node Globals/APIs:**
|
||||
- [ ] Replace `process.argv` with `Deno.args`. Note that `Deno.args` does *not* include the script name, so adjustments to slicing (like `.slice(2)`) might be needed or removed.
|
||||
- [ ] Replace `process.exit()` with `Deno.exit()`.
|
||||
- [ ] Replace or refactor any other Node-specific APIs that don't have direct Deno equivalents or aren't polyfilled via the `node:` specifier (e.g., check compatibility of `StdioServerTransport` if it relies heavily on Node streams internally, although the `npm:` specifier should handle much of this).
|
||||
- [x] **Imports:**
|
||||
- [x] Prefix all Node.js built-in module imports with `node:` (e.g., `import { EventEmitter } from 'node:events';`).
|
||||
- [x] Update imports for external npm packages to match the `npm:` specifiers defined in `deno.json` or directly use `npm:` specifiers in the import statement.
|
||||
- [x] Append the `.ts` (or `.js` if applicable) extension to all relative file imports within the `src/` directory (e.g., `import { ... } from './lib/utils.ts';`).
|
||||
- [x] **Node Globals/APIs:**
|
||||
- [x] Replace `process.argv` with `Deno.args`. Note that `Deno.args` does *not* include the script name, so adjustments to slicing (like `.slice(2)`) might be needed or removed.
|
||||
- [x] Replace `process.exit()` with `Deno.exit()`.
|
||||
- [x] Replace or refactor any other Node-specific APIs that don't have direct Deno equivalents or aren't polyfilled via the `node:` specifier (e.g., check compatibility of `StdioServerTransport` if it relies heavily on Node streams internally, although the `npm:` specifier should handle much of this).
|
||||
4. **Cleanup Project Root:**
|
||||
- [ ] Delete `pnpm-lock.yaml` and `node_modules` (if present).
|
||||
- [ ] Decide whether to keep `package.json`. It's not used by Deno for dependencies but can be useful for metadata (name, version, description). If kept, ensure it doesn't cause confusion.
|
||||
- [ ] Remove `tsconfig.json` if all necessary compiler options are migrated to `deno.json`. Linters/editors might still pick it up, so consider keeping it for tooling compatibility if needed, but `deno.json` takes precedence for Deno itself.
|
||||
- [x] Delete `pnpm-lock.yaml` and `node_modules` (if present).
|
||||
- [x] Decide whether to keep `package.json`. It's not used by Deno for dependencies but can be useful for metadata (name, version, description). If kept, ensure it doesn't cause confusion.
|
||||
- [x] Remove `tsconfig.json` if all necessary compiler options are migrated to `deno.json`. Linters/editors might still pick it up, so consider keeping it for tooling compatibility if needed, but `deno.json` takes precedence for Deno itself.
|
||||
5. **Testing:**
|
||||
- [ ] Run the main task using `deno task start <args...>`.
|
||||
- [ ] 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.
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { checkLockfile, createLockfile, deleteLockfile, getConfigFilePath, LockfileData } from './mcp-auth-config'
|
||||
import { EventEmitter } from 'events'
|
||||
import { Server } from 'http'
|
||||
import express from 'express'
|
||||
import { AddressInfo } from 'net'
|
||||
import { log, setupOAuthCallbackServerWithLongPoll } from './utils'
|
||||
import { checkLockfile, createLockfile, deleteLockfile, getConfigFilePath, type LockfileData } from './mcp-auth-config.ts'
|
||||
import type { EventEmitter } from 'node:events'
|
||||
import type { Server } from 'node:http'
|
||||
import express from 'npm:express'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { log, setupOAuthCallbackServerWithLongPoll } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Checks if a process with the given PID is running
|
||||
|
@ -12,10 +12,37 @@ import { log, setupOAuthCallbackServerWithLongPoll } from './utils'
|
|||
*/
|
||||
export async function isPidRunning(pid: number): Promise<boolean> {
|
||||
try {
|
||||
process.kill(pid, 0) // Doesn't kill the process, just checks if it exists
|
||||
return true
|
||||
// Deno doesn't have a direct equivalent to process.kill(pid, 0)
|
||||
// On non-Windows platforms, we can try to use kill system call to check
|
||||
if (Deno.build.os !== 'windows') {
|
||||
try {
|
||||
// Using Deno.run to check if process exists
|
||||
const command = new Deno.Command('kill', {
|
||||
args: ['-0', pid.toString()],
|
||||
stdout: 'null',
|
||||
stderr: 'null',
|
||||
});
|
||||
const { success } = await command.output();
|
||||
return success;
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// On Windows, use tasklist to check if process exists
|
||||
try {
|
||||
const command = new Deno.Command('tasklist', {
|
||||
args: ['/FI', `PID eq ${pid}`, '/NH'],
|
||||
stdout: 'piped',
|
||||
});
|
||||
const { stdout } = await command.output();
|
||||
const output = new TextDecoder().decode(stdout);
|
||||
return output.includes(pid.toString());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -71,11 +98,12 @@ export async function waitForAuthentication(port: number): Promise<boolean> {
|
|||
|
||||
if (response.status === 200) {
|
||||
// Auth completed, but we don't return the code anymore
|
||||
log(`Authentication completed by other instance`)
|
||||
log('Authentication completed by other instance')
|
||||
return true
|
||||
} else if (response.status === 202) {
|
||||
}
|
||||
if (response.status === 202) {
|
||||
// Continue polling
|
||||
log(`Authentication still in progress`)
|
||||
log('Authentication still in progress')
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
} else {
|
||||
log(`Unexpected response status: ${response.status}`)
|
||||
|
@ -101,7 +129,7 @@ export async function coordinateAuth(
|
|||
events: EventEmitter,
|
||||
): Promise<{ server: Server; waitForAuthCode: () => Promise<string>; skipBrowserAuth: boolean }> {
|
||||
// Check for a lockfile (disabled on Windows for the time being)
|
||||
const lockData = process.platform === 'win32' ? null : await checkLockfile(serverUrlHash)
|
||||
const lockData = Deno.build.os === 'windows' ? null : await checkLockfile(serverUrlHash)
|
||||
|
||||
// If there's a valid lockfile, try to use the existing auth process
|
||||
if (lockData && (await isLockValid(lockData))) {
|
||||
|
@ -128,9 +156,8 @@ export async function coordinateAuth(
|
|||
waitForAuthCode: dummyWaitForAuthCode,
|
||||
skipBrowserAuth: true,
|
||||
}
|
||||
} else {
|
||||
log('Taking over authentication process...')
|
||||
}
|
||||
log('Taking over authentication process...')
|
||||
} catch (error) {
|
||||
log(`Error waiting for authentication: ${error}`)
|
||||
}
|
||||
|
@ -144,7 +171,7 @@ export async function coordinateAuth(
|
|||
}
|
||||
|
||||
// Create our own lockfile
|
||||
const { server, waitForAuthCode, authCompletedPromise } = setupOAuthCallbackServerWithLongPoll({
|
||||
const { server, waitForAuthCode, authCompletedPromise: _ } = setupOAuthCallbackServerWithLongPoll({
|
||||
port: callbackPort,
|
||||
path: '/oauth/callback',
|
||||
events,
|
||||
|
@ -154,8 +181,8 @@ export async function coordinateAuth(
|
|||
const address = server.address() as AddressInfo
|
||||
const actualPort = address.port
|
||||
|
||||
log(`Creating lockfile for server ${serverUrlHash} with process ${process.pid} on port ${actualPort}`)
|
||||
await createLockfile(serverUrlHash, process.pid, actualPort)
|
||||
log(`Creating lockfile for server ${serverUrlHash} with process ${Deno.pid} on port ${actualPort}`)
|
||||
await createLockfile(serverUrlHash, Deno.pid, actualPort)
|
||||
|
||||
// Make sure lockfile is deleted on process exit
|
||||
const cleanupHandler = async () => {
|
||||
|
@ -167,18 +194,29 @@ export async function coordinateAuth(
|
|||
}
|
||||
}
|
||||
|
||||
process.once('exit', () => {
|
||||
// Setup exit handlers for Deno
|
||||
// Note: Deno doesn't have process.once but we can use addEventListener
|
||||
// Use unload event instead of beforeunload signal
|
||||
addEventListener("unload", () => {
|
||||
try {
|
||||
// Synchronous version for 'exit' event since we can't use async here
|
||||
// Synchronous cleanup
|
||||
const configPath = getConfigFilePath(serverUrlHash, 'lock.json')
|
||||
require('fs').unlinkSync(configPath)
|
||||
} catch {}
|
||||
})
|
||||
// Use Deno's synchronous file API
|
||||
try {
|
||||
Deno.removeSync(configPath);
|
||||
} catch (_) {
|
||||
// Ignore errors
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore errors during exit
|
||||
}
|
||||
});
|
||||
|
||||
// Also handle SIGINT separately
|
||||
process.once('SIGINT', async () => {
|
||||
await cleanupHandler()
|
||||
})
|
||||
Deno.addSignalListener("SIGINT", async () => {
|
||||
await cleanupHandler();
|
||||
Deno.exit(0);
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import path from 'path'
|
||||
import os from 'os'
|
||||
import fs from 'fs/promises'
|
||||
import { log, MCP_REMOTE_VERSION } from './utils'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { log, MCP_REMOTE_VERSION } from './utils.ts'
|
||||
|
||||
/**
|
||||
* MCP Remote Authentication Configuration
|
||||
|
@ -82,7 +81,7 @@ export async function deleteLockfile(serverUrlHash: string): Promise<void> {
|
|||
* @returns The path to the configuration directory
|
||||
*/
|
||||
export function getConfigDir(): string {
|
||||
const baseConfigDir = process.env.MCP_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.mcp-auth')
|
||||
const baseConfigDir = Deno.env.get('MCP_REMOTE_CONFIG_DIR') || path.join(os.homedir(), '.mcp-auth')
|
||||
// Add a version subdirectory so we don't need to worry about backwards/forwards compatibility yet
|
||||
return path.join(baseConfigDir, `mcp-remote-${MCP_REMOTE_VERSION}`)
|
||||
}
|
||||
|
@ -93,7 +92,7 @@ export function getConfigDir(): string {
|
|||
export async function ensureConfigDir(): Promise<void> {
|
||||
try {
|
||||
const configDir = getConfigDir()
|
||||
await fs.mkdir(configDir, { recursive: true })
|
||||
await Deno.mkdir(configDir, { recursive: true })
|
||||
} catch (error) {
|
||||
log('Error creating config directory:', error)
|
||||
throw error
|
||||
|
@ -119,10 +118,10 @@ export function getConfigFilePath(serverUrlHash: string, filename: string): stri
|
|||
export async function deleteConfigFile(serverUrlHash: string, filename: string): Promise<void> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
||||
await fs.unlink(filePath)
|
||||
await Deno.remove(filePath)
|
||||
} catch (error) {
|
||||
// Ignore if file doesn't exist
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
if ((error as Deno.errors.NotFound).name !== 'NotFound') {
|
||||
log(`Error deleting ${filename}:`, error)
|
||||
}
|
||||
}
|
||||
|
@ -140,12 +139,12 @@ export async function readJsonFile<T>(serverUrlHash: string, filename: string, s
|
|||
await ensureConfigDir()
|
||||
|
||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const content = await Deno.readTextFile(filePath)
|
||||
const result = await schema.parseAsync(JSON.parse(content))
|
||||
// console.log({ filename: result })
|
||||
return result
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
// console.log(`File ${filename} does not exist`)
|
||||
return undefined
|
||||
}
|
||||
|
@ -164,7 +163,7 @@ export async function writeJsonFile(serverUrlHash: string, filename: string, dat
|
|||
try {
|
||||
await ensureConfigDir()
|
||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
await Deno.writeTextFile(filePath, JSON.stringify(data, null, 2))
|
||||
} catch (error) {
|
||||
log(`Error writing ${filename}:`, error)
|
||||
throw error
|
||||
|
@ -182,7 +181,7 @@ export async function readTextFile(serverUrlHash: string, filename: string, erro
|
|||
try {
|
||||
await ensureConfigDir()
|
||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
||||
return await fs.readFile(filePath, 'utf-8')
|
||||
return await Deno.readTextFile(filePath)
|
||||
} catch (error) {
|
||||
throw new Error(errorMessage || `Error reading ${filename}`)
|
||||
}
|
||||
|
@ -198,7 +197,7 @@ export async function writeTextFile(serverUrlHash: string, filename: string, tex
|
|||
try {
|
||||
await ensureConfigDir()
|
||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
||||
await fs.writeFile(filePath, text, 'utf-8')
|
||||
await Deno.writeTextFile(filePath, text)
|
||||
} catch (error) {
|
||||
log(`Error writing ${filename}:`, error)
|
||||
throw error
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import open from 'open'
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import open from 'npm:open'
|
||||
import { OAuthClientProvider } from 'npm:@modelcontextprotocol/sdk/client/auth.js'
|
||||
import {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientInformationSchema,
|
||||
OAuthTokens,
|
||||
OAuthTokensSchema,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import type { OAuthProviderOptions } from './types'
|
||||
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile } from './mcp-auth-config'
|
||||
import { getServerUrlHash, log, MCP_REMOTE_VERSION } from './utils'
|
||||
} from 'npm:@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import type { OAuthProviderOptions } from './types.ts'
|
||||
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile } from './mcp-auth-config.ts'
|
||||
import { getServerUrlHash, log, MCP_REMOTE_VERSION } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Implements the OAuthClientProvider interface for Node.js environments.
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { EventEmitter } from 'events'
|
||||
import type { EventEmitter } from 'node:events'
|
||||
|
||||
/**
|
||||
* Options for creating an OAuth client provider
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
import { OAuthCallbackServerOptions } from './types'
|
||||
import express from 'express'
|
||||
import net from 'net'
|
||||
import crypto from '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 package.json
|
||||
export const MCP_REMOTE_VERSION = require('../../package.json').version
|
||||
// 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
|
||||
|
||||
const pid = process.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)
|
||||
|
|
19
src/proxy.ts
19
src/proxy.ts
|
@ -1,19 +1,20 @@
|
|||
#!/usr/bin/env node
|
||||
/// <reference lib="deno.ns" />
|
||||
|
||||
/**
|
||||
* MCP Proxy with OAuth support
|
||||
* A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication.
|
||||
*
|
||||
* Run with: npx tsx proxy.ts https://example.remote/server [callback-port]
|
||||
* Run with: deno run --allow-net --allow-env --allow-read --allow-run src/proxy.ts https://example.remote/server [callback-port]
|
||||
*
|
||||
* If callback-port is not specified, an available port will be automatically selected.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash } from './lib/utils'
|
||||
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
|
||||
import { coordinateAuth } from './lib/coordination'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { StdioServerTransport } from 'npm:@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash } from './lib/utils.ts'
|
||||
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider.ts'
|
||||
import { coordinateAuth } from './lib/coordination.ts'
|
||||
|
||||
/**
|
||||
* Main function to run the proxy
|
||||
|
@ -94,16 +95,16 @@ to the CA certificate file. If using claude_desktop_config.json, this might look
|
|||
`)
|
||||
}
|
||||
server.close()
|
||||
process.exit(1)
|
||||
Deno.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command-line arguments and run the proxy
|
||||
parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]')
|
||||
parseCommandLineArgs(Deno.args, 3334, 'Usage: deno run src/proxy.ts <https://server-url> [callback-port]')
|
||||
.then(({ serverUrl, callbackPort, headers }) => {
|
||||
return runProxy(serverUrl, callbackPort, headers)
|
||||
})
|
||||
.catch((error) => {
|
||||
log('Fatal error:', error)
|
||||
process.exit(1)
|
||||
Deno.exit(1)
|
||||
})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue