This commit is contained in:
dorshany 2025-04-28 10:31:25 -07:00
commit 85b9f0b363
9 changed files with 134 additions and 55 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules
.mcp-cli
dist
.idea

View file

@ -10,7 +10,7 @@ So far, the majority of MCP servers in the wild are installed locally, using the
But there's a reason most software that _could_ be moved to the web _did_ get moved to the web: it's so much easier to find and fix bugs & iterate on new features when you can push updates to all your users with a single deploy.
With the MCP [Authorization specification](https://spec.modelcontextprotocol.io/specification/draft/basic/authorization/) nearing completion, we now have a secure way of sharing our MCP servers with the world _without_ running code on user's laptops. Or at least, you would, if all the popular MCP _clients_ supported it yet. Most are stdio-only, and those that _do_ support HTTP+SSE don't yet support the OAuth flows required.
With the latest MCP [Authorization specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization), we now have a secure way of sharing our MCP servers with the world _without_ running code on user's laptops. Or at least, you would, if all the popular MCP _clients_ supported it yet. Most are stdio-only, and those that _do_ support HTTP+SSE don't yet support the OAuth flows required.
That's where `mcp-remote` comes in. As soon as your chosen MCP client supports remote, authorized servers, you can remove it. Until that time, drop in this one liner and dress for the MCP clients you want!
@ -55,6 +55,23 @@ To bypass authentication, or to emit custom headers on all requests to your remo
}
```
**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
{
// rest of config...
"args": [
"mcp-remote",
"https://remote.mcp.server/sse",
"--header",
"Authorization:${AUTH_HEADER}" // note no spaces around ':'
]
},
"env": {
"AUTH_HEADER": "Bearer <auth-token>" // spaces OK in env vars
}
```
### Flags
* If `npx` is producing errors, consider adding `-y` as the first argument to auto-accept the installation of the `mcp-remote` package.
@ -62,12 +79,22 @@ To bypass authentication, or to emit custom headers on all requests to your remo
```json
"command": "npx",
"args": [
"-y"
"-y",
"mcp-remote",
"https://remote.mcp.server/sse"
]
```
* To use Streamable HTTP instead of Server-Sent Events (SSE), add the `--streamableHttp` flag. This is recommended as SSE is deprecated:
```json
"args": [
"mcp-remote",
"https://remote.mcp.server/sse",
"--streamableHttp"
]
```
* To force `npx` to always check for an updated version of `mcp-remote`, add the `@latest` flag:
```json
@ -87,6 +114,16 @@ To bypass authentication, or to emit custom headers on all requests to your remo
]
```
* To allow HTTP connections in trusted private networks, add the `--allow-http` flag. Note: This should only be used in secure private networks where traffic cannot be intercepted.
```json
"args": [
"mcp-remote",
"http://internal-service.vpc/sse",
"--allow-http"
]
```
### Claude Desktop
[Official Docs](https://modelcontextprotocol.io/quickstart/user)

View file

@ -31,7 +31,7 @@
"check": "prettier --check . && tsc"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.7.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"express": "^4.21.2",
"open": "^10.1.0"
},

19
pnpm-lock.yaml generated
View file

@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.7.0
version: 1.7.0
specifier: ^1.10.2
version: 1.10.2
express:
specifier: ^4.21.2
version: 4.21.2
@ -217,8 +217,8 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@modelcontextprotocol/sdk@1.7.0':
resolution: {integrity: sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==}
'@modelcontextprotocol/sdk@1.10.2':
resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==}
engines: {node: '>=18'}
'@pkgjs/parseargs@0.11.0':
@ -860,8 +860,8 @@ packages:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
pkce-challenge@4.1.0:
resolution: {integrity: sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==}
pkce-challenge@5.0.0:
resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==}
engines: {node: '>=16.20.0'}
postcss-load-config@6.0.1:
@ -1238,14 +1238,15 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@modelcontextprotocol/sdk@1.7.0':
'@modelcontextprotocol/sdk@1.10.2':
dependencies:
content-type: 1.0.5
cors: 2.8.5
cross-spawn: 7.0.6
eventsource: 3.0.5
express: 5.0.1
express-rate-limit: 7.5.0(express@5.0.1)
pkce-challenge: 4.1.0
pkce-challenge: 5.0.0
raw-body: 3.0.0
zod: 3.24.2
zod-to-json-schema: 3.24.5(zod@3.24.2)
@ -1885,7 +1886,7 @@ snapshots:
pirates@4.0.6: {}
pkce-challenge@4.1.0: {}
pkce-challenge@5.0.0: {}
postcss-load-config@6.0.1(tsx@4.19.3):
dependencies:

View file

@ -2,7 +2,7 @@
/**
* MCP Client with OAuth support
* A command-line client that connects to an MCP server using SSE with OAuth authentication.
* A command-line client that connects to an MCP server using StreamableHTTP with OAuth authentication.
*
* Run with: npx tsx client.ts https://example.remote/server [callback-port]
*
@ -11,6 +11,7 @@
import { EventEmitter } from 'events'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
@ -21,7 +22,7 @@ import { coordinateAuth } from './lib/coordination'
/**
* Main function to run the client
*/
async function runClient(serverUrl: string, callbackPort: number, headers: Record<string, string>) {
async function runClient(serverUrl: string, callbackPort: number, headers: Record<string, string>, useStreamableHttp: boolean = false) {
// Set up event emitter for auth flow
const events = new EventEmitter()
@ -60,7 +61,22 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor
// Create the transport factory
const url = new URL(serverUrl)
function initTransport() {
const transport = new SSEClientTransport(url, { authProvider, requestInit: { headers } })
// Choose between Streamable HTTP or SSE transport based on flag
const transport = useStreamableHttp
? new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: { headers },
reconnectionOptions: {
initialReconnectionDelay: 1000,
maxReconnectionDelay: 10000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 10,
},
})
: new SSEClientTransport(url, {
authProvider,
requestInit: { headers }
})
// Set up message and error handlers
transport.onmessage = (message) => {
@ -155,9 +171,9 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor
}
// Parse command-line arguments and run the client
parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts <https://server-url> [callback-port]')
.then(({ serverUrl, callbackPort, headers }) => {
return runClient(serverUrl, callbackPort, headers)
parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts <https://server-url> [callback-port] [--streamableHttp]')
.then(({ serverUrl, callbackPort, headers, useStreamableHttp }) => {
return runClient(serverUrl, callbackPort, headers, useStreamableHttp)
})
.catch((error) => {
console.error('Fatal error:', error)

View file

@ -9,7 +9,7 @@ import {
} from '@modelcontextprotocol/sdk/shared/auth.js'
import type { OAuthProviderOptions } from './types'
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile } from './mcp-auth-config'
import { getServerUrlHash, log } from './utils'
import { getServerUrlHash, log, MCP_REMOTE_VERSION } from './utils'
/**
* Implements the OAuthClientProvider interface for Node.js environments.
@ -20,6 +20,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
private callbackPath: string
private clientName: string
private clientUri: string
private softwareId: string
private softwareVersion: string
/**
* Creates a new NodeOAuthClientProvider
@ -30,6 +32,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
this.callbackPath = options.callbackPath || '/oauth/callback'
this.clientName = options.clientName || 'MCP CLI Client'
this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli'
this.softwareId = options.softwareId || '2e6dc280-f3c3-4e01-99a7-8181dbd1d23d'
this.softwareVersion = options.softwareVersion || MCP_REMOTE_VERSION
}
get redirectUrl(): string {
@ -44,6 +48,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
response_types: ['code'],
client_name: this.clientName,
client_uri: this.clientUri,
software_id: this.softwareId,
software_version: this.softwareVersion,
}
}

View file

@ -16,6 +16,10 @@ export interface OAuthProviderOptions {
clientName?: string
/** Client URI to use for OAuth registration */
clientUri?: string
/** Software ID to use for OAuth registration */
softwareId?: string
/** Software version to use for OAuth registration */
softwareVersion?: string
}
/**

View file

@ -1,4 +1,5 @@
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { OAuthCallbackServerOptions } from './types'
@ -65,13 +66,14 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
}
/**
* Creates and connects to a remote SSE server with OAuth authentication
* Creates and connects to a remote server with OAuth authentication
* @param serverUrl The URL of the remote server
* @param authProvider The OAuth client provider
* @param headers Additional headers to send with the request
* @param waitForAuthCode Function to wait for the auth code
* @param skipBrowserAuth Whether to skip browser auth and use shared auth
* @returns The connected SSE client transport
* @param useStreamableHttp Whether to use Streamable HTTP transport instead of SSE
* @returns The connected client transport
*/
export async function connectToRemoteServer(
serverUrl: string,
@ -79,32 +81,27 @@ export async function connectToRemoteServer(
headers: Record<string, string>,
waitForAuthCode: () => Promise<string>,
skipBrowserAuth: boolean = false,
): Promise<SSEClientTransport> {
useStreamableHttp: boolean = false,
): Promise<StreamableHTTPClientTransport | SSEClientTransport> {
log(`[${pid}] Connecting to remote server: ${serverUrl}`)
const url = new URL(serverUrl)
// Create transport with eventSourceInit to pass Authorization header if present
const eventSourceInit = {
fetch: (url: string | URL, init?: RequestInit) => {
return Promise.resolve(authProvider?.tokens?.()).then((tokens) =>
fetch(url, {
...init,
headers: {
...(init?.headers as Record<string, string> | undefined),
...headers,
...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}),
Accept: "text/event-stream",
} as Record<string, string>,
})
);
},
};
const transport = new SSEClientTransport(url, {
authProvider,
requestInit: { headers },
eventSourceInit,
})
// Create the appropriate transport (Streamable HTTP or SSE) based on the flag
const transport = useStreamableHttp
? new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: { headers },
reconnectionOptions: {
initialReconnectionDelay: 1000,
maxReconnectionDelay: 10000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 10,
},
})
: new SSEClientTransport(url, {
authProvider,
requestInit: { headers }
})
try {
await transport.start()
@ -125,8 +122,22 @@ export async function connectToRemoteServer(
log('Completing authorization...')
await transport.finishAuth(code)
// Create a new transport after auth
const newTransport = new SSEClientTransport(url, { authProvider, requestInit: { headers } })
// Create a new transport (Streamable HTTP or SSE) after auth with the same type as before
const newTransport = useStreamableHttp
? new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: { headers },
reconnectionOptions: {
initialReconnectionDelay: 1000,
maxReconnectionDelay: 10000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 10,
},
})
: new SSEClientTransport(url, {
authProvider,
requestInit: { headers }
})
await newTransport.start()
log('Connected to remote server after authentication')
return newTransport
@ -299,6 +310,8 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
const serverUrl = args[0]
const specifiedPort = args[1] ? parseInt(args[1]) : undefined
const allowHttp = args.includes('--allow-http')
const useStreamableHttp = args.includes('--streamableHttp')
if (!serverUrl) {
log(usage)
@ -308,7 +321,8 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
const url = new URL(serverUrl)
const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:'
if (!(url.protocol == 'https:' || isLocalhost)) {
if (!(url.protocol == 'https:' || isLocalhost || allowHttp)) {
log('Error: Non-HTTPS URLs are only allowed for localhost or when --allow-http flag is provided')
log(usage)
process.exit(1)
}
@ -341,7 +355,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
})
}
return { serverUrl, callbackPort, headers }
return { serverUrl, callbackPort, headers, useStreamableHttp }
}
/**

View file

@ -2,7 +2,7 @@
/**
* MCP Proxy with OAuth support
* A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication.
* A bidirectional proxy between a local STDIO MCP server and a remote server with OAuth authentication.
*
* Run with: npx tsx proxy.ts https://example.remote/server [callback-port]
*
@ -18,7 +18,7 @@ import { coordinateAuth } from './lib/coordination'
/**
* Main function to run the proxy
*/
async function runProxy(serverUrl: string, callbackPort: number, headers: Record<string, string>) {
async function runProxy(serverUrl: string, callbackPort: number, headers: Record<string, string>, useStreamableHttp: boolean = false) {
// Set up event emitter for auth flow
const events = new EventEmitter()
@ -48,7 +48,7 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record
try {
// Connect to remote server with authentication
const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, headers, waitForAuthCode, skipBrowserAuth)
const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, headers, waitForAuthCode, skipBrowserAuth, useStreamableHttp)
// Set up bidirectional proxy between local and remote transports
mcpProxy({
@ -59,7 +59,7 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record
// Start the local STDIO server
await localTransport.start()
log('Local STDIO server running')
log('Proxy established successfully between local STDIO and remote SSE')
log('Proxy established successfully between local STDIO and remote server')
log('Press Ctrl+C to exit')
// Setup cleanup handler
@ -99,9 +99,9 @@ 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), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]')
.then(({ serverUrl, callbackPort, headers }) => {
return runProxy(serverUrl, callbackPort, headers)
parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port] [--streamableHttp]')
.then(({ serverUrl, callbackPort, headers, useStreamableHttp }) => {
return runProxy(serverUrl, callbackPort, headers, useStreamableHttp)
})
.catch((error) => {
log('Fatal error:', error)