Compare commits

..

2 commits

Author SHA1 Message Date
Glen Maddern
bd5544acc6 Added a 2 second delay before closing the browser 2025-05-06 08:51:18 +10:00
dp-rufus
477cfce238 Attempt auto close 2025-05-06 08:49:44 +10:00
8 changed files with 47 additions and 98 deletions

View file

@ -1,12 +1,11 @@
name: Publish Any Commit name: Publish Any Commit
on: on:
workflow_dispatch:
pull_request: pull_request:
push: push:
branches: branches:
- "**" - "**"
tags: tags:
- "v*" - "!**"
jobs: jobs:
build: build:
@ -16,18 +15,16 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Add git.kvant.cloud scope - run: corepack enable
run: npm config set @kvant:registry=https://git.kvant.cloud/api/packages/${{ github.repository_owner }}/npm/ - uses: actions/setup-node@v4
- name: Login to git.kvant.cloud npm
run: npm config set -- '//git.kvant.cloud/api/packages/${{ github.repository_owner }}/npm/:_authToken' "${{ secrets.PHOENIX_PACKAGE_WRITER_TOKEN }}"
- name: Setup pnpm & install
uses: https://github.com/wyvox/action-setup-pnpm@v3
with: with:
node-version: 22 node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build - name: Build
run: pnpm build run: pnpm build
- run: pnpm dlx publish --compact --bin - run: pnpm dlx pkg-pr-new publish --compact --bin

View file

@ -46,11 +46,11 @@ To bypass authentication, or to emit custom headers on all requests to your remo
"https://remote.mcp.server/sse", "https://remote.mcp.server/sse",
"--header", "--header",
"Authorization: Bearer ${AUTH_TOKEN}" "Authorization: Bearer ${AUTH_TOKEN}"
], ]
},
"env": { "env": {
"AUTH_TOKEN": "..." "AUTH_TOKEN": "..."
} }
},
} }
} }
``` ```
@ -65,11 +65,11 @@ To bypass authentication, or to emit custom headers on all requests to your remo
"https://remote.mcp.server/sse", "https://remote.mcp.server/sse",
"--header", "--header",
"Authorization:${AUTH_HEADER}" // note no spaces around ':' "Authorization:${AUTH_HEADER}" // note no spaces around ':'
], ]
"env": {
"AUTH_HEADER": "Bearer <auth-token>" // spaces OK in env vars
}
}, },
"env": {
"AUTH_HEADER": "Bearer <auth-token>" // spaces OK in env vars
}
``` ```
### Flags ### Flags

View file

@ -1,6 +1,7 @@
{ {
"name": "@kvant/mcp-remote", "name": "mcp-remote",
"version": "0.1.5", "version": "0.1.2",
"packageManager": "pnpm@8.15.1",
"description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth",
"keywords": [ "keywords": [
"mcp", "mcp",
@ -31,9 +32,8 @@
"express": "^4.21.2", "express": "^4.21.2",
"open": "^10.1.0" "open": "^10.1.0"
}, },
"packageManager": "pnpm@10.11.0",
"devDependencies": { "devDependencies": {
"@modelcontextprotocol/sdk": "^1.11.2", "@modelcontextprotocol/sdk": "^1.10.2",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"prettier": "^3.5.3", "prettier": "^3.5.3",

10
pnpm-lock.yaml generated
View file

@ -16,8 +16,8 @@ importers:
version: 10.1.0 version: 10.1.0
devDependencies: devDependencies:
'@modelcontextprotocol/sdk': '@modelcontextprotocol/sdk':
specifier: ^1.11.2 specifier: ^1.10.2
version: 1.11.2 version: 1.10.2
'@types/express': '@types/express':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0 version: 5.0.0
@ -211,8 +211,8 @@ packages:
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@modelcontextprotocol/sdk@1.11.2': '@modelcontextprotocol/sdk@1.10.2':
resolution: {integrity: sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==} resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
@ -1206,7 +1206,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
'@modelcontextprotocol/sdk@1.11.2': '@modelcontextprotocol/sdk@1.10.2':
dependencies: dependencies:
content-type: 1.0.5 content-type: 1.0.5
cors: 2.8.5 cors: 2.8.5

View file

@ -151,7 +151,7 @@ async function runClient(
} }
// Parse command-line arguments and run the client // Parse command-line arguments and run the client
parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts <https://server-url> [callback-port]') parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts <https://server-url> [callback-port]')
.then(({ serverUrl, callbackPort, headers, transportStrategy }) => { .then(({ serverUrl, callbackPort, headers, transportStrategy }) => {
return runClient(serverUrl, callbackPort, headers, transportStrategy) return runClient(serverUrl, callbackPort, headers, transportStrategy)
}) })

View file

@ -1,8 +1,9 @@
import open from 'open' import open from 'open'
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import { import {
OAuthClientInformation,
OAuthClientInformationFull, OAuthClientInformationFull,
OAuthClientInformationFullSchema, OAuthClientInformationSchema,
OAuthTokens, OAuthTokens,
OAuthTokensSchema, OAuthTokensSchema,
} from '@modelcontextprotocol/sdk/shared/auth.js' } from '@modelcontextprotocol/sdk/shared/auth.js'
@ -36,7 +37,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
} }
get redirectUrl(): string { get redirectUrl(): string {
return `http://localhost:${this.options.callbackPort}${this.callbackPath}` return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}`
} }
get clientMetadata() { get clientMetadata() {
@ -56,9 +57,9 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
* 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<OAuthClientInformationFull | undefined> { async clientInformation(): Promise<OAuthClientInformation | undefined> {
// log('Reading client info') // log('Reading client info')
return readJsonFile<OAuthClientInformationFull>(this.serverUrlHash, 'client_info.json', OAuthClientInformationFullSchema) return readJsonFile<OAuthClientInformation>(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema)
} }
/** /**

View file

@ -3,13 +3,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { OAuthClientInformationFull, OAuthClientInformationFullSchema } from '@modelcontextprotocol/sdk/shared/auth.js'
import { OAuthCallbackServerOptions } from './types'
import { getConfigFilePath, readJsonFile } from './mcp-auth-config'
import express from 'express'
import net from 'net'
import crypto from 'crypto'
import fs from 'fs/promises'
// Connection constants // Connection constants
export const REASON_AUTH_NEEDED = 'authentication-needed' export const REASON_AUTH_NEEDED = 'authentication-needed'
@ -17,6 +10,10 @@ export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport'
// Transport strategy types // Transport strategy types
export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first' export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first'
import { OAuthCallbackServerOptions } from './types'
import express from 'express'
import net from 'net'
import crypto from 'crypto'
// Package version from package.json // Package version from package.json
export const MCP_REMOTE_VERSION = require('../../package.json').version export const MCP_REMOTE_VERSION = require('../../package.json').version
@ -35,21 +32,14 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
let transportToClientClosed = false let transportToClientClosed = false
let transportToServerClosed = false let transportToServerClosed = false
transportToClient.onmessage = (_message) => { transportToClient.onmessage = (message) => {
// TODO: fix types // @ts-expect-error TODO
const message = _message as any
log('[Local→Remote]', message.method || message.id) log('[Local→Remote]', message.method || message.id)
if (message.method === 'initialize') {
const { clientInfo } = message.params
if (clientInfo) clientInfo.name = `${clientInfo.name} (via mcp-remote ${MCP_REMOTE_VERSION})`
log(JSON.stringify(message, null, 2))
}
transportToServer.send(message).catch(onServerError) transportToServer.send(message).catch(onServerError)
} }
transportToServer.onmessage = (_message) => { transportToServer.onmessage = (message) => {
// TODO: fix types // @ts-expect-error TODO: fix this type
const message = _message as any
log('[Remote→Local]', message.method || message.id) log('[Remote→Local]', message.method || message.id)
transportToClient.send(message).catch(onClientError) transportToClient.send(message).catch(onClientError)
} }
@ -355,27 +345,6 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
return { server, authCode, waitForAuthCode } return { server, authCode, waitForAuthCode }
} }
async function findExistingClientPort(serverUrlHash: string): Promise<number | undefined> {
const clientInfo = await readJsonFile<OAuthClientInformationFull>(serverUrlHash, 'client_info.json', OAuthClientInformationFullSchema)
if (!clientInfo) {
return undefined
}
const localhostRedirectUri = clientInfo.redirect_uris.map((uri) => new URL(uri)).find(({ hostname }) => hostname === 'localhost')
if (!localhostRedirectUri) {
throw new Error('Cannot find localhost callback URI from existing client information')
}
return parseInt(localhostRedirectUri.port)
}
function calculateDefaultPort(serverUrlHash: string): number {
// Convert the first 4 bytes of the serverUrlHash into a port offset
const offset = parseInt(serverUrlHash.substring(0, 4), 16)
// Pick a consistent but random-seeming port from 3335 to 49151
return 3335 + (offset % 45816)
}
/** /**
* Finds an available port on the local machine * Finds an available port on the local machine
* @param preferredPort Optional preferred port to try first * @param preferredPort Optional preferred port to try first
@ -409,10 +378,11 @@ export async function findAvailablePort(preferredPort?: number): Promise<number>
/** /**
* Parses command line arguments for MCP clients and proxies * Parses command line arguments for MCP clients and proxies
* @param args Command line arguments * @param args Command line arguments
* @param defaultPort Default port for the callback server if specified port is unavailable
* @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[], usage: string) { export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) {
// Process headers // Process headers
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
let i = 0 let i = 0
@ -462,28 +432,14 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
log(usage) log(usage)
process.exit(1) process.exit(1)
} }
const serverUrlHash = getServerUrlHash(serverUrl)
const defaultPort = calculateDefaultPort(serverUrlHash)
// Use the specified port, or the existing client port or fallback to find an available one // Use the specified port, or find an available one
const [existingClientPort, availablePort] = await Promise.all([findExistingClientPort(serverUrlHash), findAvailablePort(defaultPort)]) const callbackPort = specifiedPort || (await findAvailablePort(defaultPort))
let callbackPort: number
if (specifiedPort) { if (specifiedPort) {
if (existingClientPort && specifiedPort !== existingClientPort) { log(`Using specified callback port: ${callbackPort}`)
log(
`Warning! Specified callback port of ${specifiedPort}, which conflicts with existing client registration port ${existingClientPort}. Deleting existing client data to force reregistration.`,
)
await fs.rm(getConfigFilePath(serverUrlHash, 'client_info.json'))
}
log(`Using specified callback port: ${specifiedPort}`)
callbackPort = specifiedPort
} else if (existingClientPort) {
log(`Using existing client port: ${existingClientPort}`)
callbackPort = existingClientPort
} else { } else {
log(`Using automatically selected callback port: ${availablePort}`) log(`Using automatically selected callback port: ${callbackPort}`)
callbackPort = availablePort
} }
if (Object.keys(headers).length > 0) { if (Object.keys(headers).length > 0) {
@ -521,11 +477,6 @@ export function setupSignalHandlers(cleanup: () => Promise<void>) {
// Keep the process alive // Keep the process alive
process.stdin.resume() process.stdin.resume()
process.stdin.on('end', async () => {
log('\nShutting down...')
await cleanup()
process.exit(0)
})
} }
/** /**

View file

@ -135,7 +135,7 @@ to the CA certificate file. If using claude_desktop_config.json, this might look
} }
// Parse command-line arguments and run the proxy // Parse command-line arguments and run the proxy
parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]') parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]')
.then(({ serverUrl, callbackPort, headers, transportStrategy }) => { .then(({ serverUrl, callbackPort, headers, transportStrategy }) => {
return runProxy(serverUrl, callbackPort, headers, transportStrategy) return runProxy(serverUrl, callbackPort, headers, transportStrategy)
}) })