Compare commits

..

2 commits

Author SHA1 Message Date
Glen Maddern
13a2a98718 0.1.0 2025-04-30 21:57:17 +10:00
Glen Maddern
083695600f Added Streamable HTTP support
This adds a new CLI argument, --transport, with the following values: http-first (the default), http-only, sse-first, and sse-only. Any of the -first tags attempts to connect to the URL as either an HTTP or SSE server and falls back to the other.
2025-04-30 21:57:09 +10:00
9 changed files with 75 additions and 160 deletions

View file

@ -1,33 +0,0 @@
name: Publish Any Commit
on:
workflow_dispatch:
pull_request:
push:
branches:
- "**"
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Add git.kvant.cloud scope
run: npm config set @kvant:registry=https://git.kvant.cloud/api/packages/${{ github.repository_owner }}/npm/
- 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:
node-version: 22
- name: Build
run: pnpm build
- run: pnpm dlx publish --compact --bin

View file

@ -46,16 +46,16 @@ To bypass authentication, or to emit custom headers on all requests to your remo
"https://remote.mcp.server/sse",
"--header",
"Authorization: Bearer ${AUTH_TOKEN}"
],
"env": {
"AUTH_TOKEN": "..."
}
]
},
"env": {
"AUTH_TOKEN": "..."
}
}
}
```
**Note:** Cursor and Claude Desktop (Windows) have a bug where spaces inside `args` aren't escaped when it invokes `npx`, which ends up mangling these values. You can work around it using:
**Note:** Cursor has a bug where spaces inside `args` aren't escaped when it invokes `npx`, which ends up mangling these values. You can work around it using:
```jsonc
{
@ -65,11 +65,11 @@ To bypass authentication, or to emit custom headers on all requests to your remo
"https://remote.mcp.server/sse",
"--header",
"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
@ -114,23 +114,6 @@ To bypass authentication, or to emit custom headers on all requests to your remo
]
```
### Transport Strategies
MCP Remote supports different transport strategies when connecting to an MCP server. This allows you to control whether it uses Server-Sent Events (SSE) or HTTP transport, and in what order it tries them.
Specify the transport strategy with the `--transport` flag:
```bash
npx mcp-remote https://example.remote/server --transport sse-only
```
**Available Strategies:**
- `http-first` (default): Tries HTTP transport first, falls back to SSE if HTTP fails with a 404 error
- `sse-first`: Tries SSE transport first, falls back to HTTP if SSE fails with a 405 error
- `http-only`: Only uses HTTP transport, fails if the server doesn't support it
- `sse-only`: Only uses SSE transport, fails if the server doesn't support it
### Claude Desktop
[Official Docs](https://modelcontextprotocol.io/quickstart/user)

View file

@ -1,6 +1,6 @@
{
"name": "@kvant/mcp-remote",
"version": "0.1.5",
"name": "mcp-remote",
"version": "0.1.0",
"description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth",
"keywords": [
"mcp",
@ -31,12 +31,13 @@
"express": "^4.21.2",
"open": "^10.1.0"
},
"packageManager": "pnpm@10.11.0",
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.11.2",
"@modelcontextprotocol/sdk": "^1.10.2",
"@types/express": "^5.0.0",
"@types/node": "^22.13.10",
"@types/react": "^19.0.12",
"prettier": "^3.5.3",
"react": "^19.0.0",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
@ -52,6 +53,8 @@
"dts": true,
"clean": true,
"outDir": "dist",
"external": []
"external": [
"react"
]
}
}

34
pnpm-lock.yaml generated
View file

@ -16,17 +16,23 @@ importers:
version: 10.1.0
devDependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.11.2
version: 1.11.2
specifier: ^1.10.2
version: 1.10.2
'@types/express':
specifier: ^5.0.0
version: 5.0.0
'@types/node':
specifier: ^22.13.10
version: 22.13.10
'@types/react':
specifier: ^19.0.12
version: 19.0.12
prettier:
specifier: ^3.5.3
version: 3.5.3
react:
specifier: ^19.0.0
version: 19.0.0
tsup:
specifier: ^8.4.0
version: 8.4.0(tsx@4.19.3)(typescript@5.8.2)
@ -211,8 +217,8 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@modelcontextprotocol/sdk@1.11.2':
resolution: {integrity: sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==}
'@modelcontextprotocol/sdk@1.10.2':
resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==}
engines: {node: '>=18'}
'@pkgjs/parseargs@0.11.0':
@ -344,6 +350,9 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react@19.0.12':
resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==}
'@types/send@0.17.4':
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
@ -470,6 +479,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@ -887,6 +899,10 @@ packages:
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
engines: {node: '>= 0.8'}
react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@ -1206,7 +1222,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@modelcontextprotocol/sdk@1.11.2':
'@modelcontextprotocol/sdk@1.10.2':
dependencies:
content-type: 1.0.5
cors: 2.8.5
@ -1318,6 +1334,10 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/react@19.0.12':
dependencies:
csstype: 3.1.3
'@types/send@0.17.4':
dependencies:
'@types/mime': 1.3.5
@ -1454,6 +1474,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
csstype@3.1.3: {}
debug@2.6.9:
dependencies:
ms: 2.0.0
@ -1874,6 +1896,8 @@ snapshots:
iconv-lite: 0.6.3
unpipe: 1.0.0
react@19.0.0: {}
readdirp@4.1.2: {}
resolve-from@5.0.0: {}

View file

@ -151,7 +151,7 @@ async function runClient(
}
// 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 }) => {
return runClient(serverUrl, callbackPort, headers, transportStrategy)
})

View file

@ -1,8 +1,9 @@
import open from 'open'
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import {
OAuthClientInformation,
OAuthClientInformationFull,
OAuthClientInformationFullSchema,
OAuthClientInformationSchema,
OAuthTokens,
OAuthTokensSchema,
} from '@modelcontextprotocol/sdk/shared/auth.js'
@ -36,7 +37,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
}
get redirectUrl(): string {
return `http://localhost:${this.options.callbackPort}${this.callbackPath}`
return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}`
}
get clientMetadata() {
@ -56,9 +57,9 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
* Gets the client information if it exists
* @returns The client information or undefined
*/
async clientInformation(): Promise<OAuthClientInformationFull | undefined> {
async clientInformation(): Promise<OAuthClientInformation | undefined> {
// 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 { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.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
export const REASON_AUTH_NEEDED = 'authentication-needed'
@ -17,6 +10,10 @@ export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport'
// Transport strategy types
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
export const MCP_REMOTE_VERSION = require('../../package.json').version
@ -35,21 +32,14 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
let transportToClientClosed = false
let transportToServerClosed = false
transportToClient.onmessage = (_message) => {
// TODO: fix types
const message = _message as any
transportToClient.onmessage = (message) => {
// @ts-expect-error TODO
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.onmessage = (_message) => {
// TODO: fix types
const message = _message as any
transportToServer.onmessage = (message) => {
// @ts-expect-error TODO: fix this type
log('[Remote→Local]', message.method || message.id)
transportToClient.send(message).catch(onClientError)
}
@ -172,10 +162,9 @@ export async function connectToRemoteServer(
if (
error instanceof Error &&
shouldAttemptFallback &&
(error.message.includes('405') ||
error.message.includes('Method Not Allowed') ||
error.message.includes('404') ||
error.message.includes('Not Found'))
(sseTransport
? error.message.includes('405') || error.message.includes('Method Not Allowed')
: error.message.includes('404') || error.message.includes('Not Found'))
) {
log(`Received error: ${error.message}`)
@ -310,16 +299,7 @@ export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServe
log('Auth code received, resolving promise')
authCompletedResolve(code)
res.send(`
Authorization successful!
You may close this window and return to the CLI.
<script>
// If this is a non-interactive session (no manual approval step was required) then
// this should automatically close the window. If not, this will have no effect and
// the user will see the message above.
window.close();
</script>
`)
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)
@ -355,27 +335,6 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
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
* @param preferredPort Optional preferred port to try first
@ -409,15 +368,15 @@ export async function findAvailablePort(preferredPort?: number): Promise<number>
/**
* Parses command line arguments for MCP clients and proxies
* @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
* @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
const headers: Record<string, string> = {}
let i = 0
while (i < args.length) {
if (args[i] === '--header' && i < args.length - 1) {
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 (match) {
@ -426,11 +385,8 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
log(`Warning: ignoring invalid header argument: ${value}`)
}
args.splice(i, 2)
// Do not increment i, as the array has shifted
continue
}
i++
}
})
const serverUrl = args[0]
const specifiedPort = args[1] ? parseInt(args[1]) : undefined
@ -462,28 +418,14 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
log(usage)
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
const [existingClientPort, availablePort] = await Promise.all([findExistingClientPort(serverUrlHash), findAvailablePort(defaultPort)])
let callbackPort: number
// Use the specified port, or find an available one
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort))
if (specifiedPort) {
if (existingClientPort && specifiedPort !== existingClientPort) {
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
log(`Using specified callback port: ${callbackPort}`)
} else {
log(`Using automatically selected callback port: ${availablePort}`)
callbackPort = availablePort
log(`Using automatically selected callback port: ${callbackPort}`)
}
if (Object.keys(headers).length > 0) {
@ -521,11 +463,6 @@ export function setupSignalHandlers(cleanup: () => Promise<void>) {
// Keep the process alive
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
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 }) => {
return runProxy(serverUrl, callbackPort, headers, transportStrategy)
})

View file

@ -7,7 +7,7 @@
"esModuleInterop": true,
"noEmit": true,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"types": ["node", "react"],
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
}