Rewrite proxy.ts for Deno
This commit is contained in:
parent
61a1f4c3cc
commit
b0216017a2
8 changed files with 1494 additions and 76 deletions
|
@ -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;
|
||||
}
|
||||
} 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
|
||||
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