Compare commits
34 commits
Author | SHA1 | Date | |
---|---|---|---|
d1cb48f770 | |||
a63b93aa5c | |||
d8ce274506 | |||
a7a76d3f17 | |||
27907a4624 | |||
0213c20d3d | |||
4f6de14fbc | |||
675dc6a760 | |||
8f83b18966 | |||
|
7eecc9ca3f | ||
|
5199279ea7 | ||
|
b1dfa9fe5b | ||
|
6f2399bbfb | ||
|
e5cdf08bc8 | ||
|
bd6df4222f | ||
|
b209d98074 | ||
|
bd75a1cdf0 | ||
|
767549412f | ||
|
46e3333416 | ||
|
63e02eef1c | ||
|
45c1739b4c | ||
|
5c71b26869 | ||
|
b9105958c1 | ||
|
114ee3c4b6 | ||
|
c9e082d9e2 | ||
|
67bd63192f | ||
|
c4a2d4a242 | ||
|
026caedd3c | ||
|
da1330d2aa | ||
|
15f9c944f6 | ||
|
2b2b12decd | ||
|
5a38b58f63 | ||
|
04e3d255b1 | ||
|
504aa26761 |
9 changed files with 198 additions and 112 deletions
33
.github/workflows/publish.yml
vendored
Normal file
33
.github/workflows/publish.yml
vendored
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
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
|
37
README.md
37
README.md
|
@ -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.
|
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!
|
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!
|
||||||
|
|
||||||
|
@ -46,16 +46,16 @@ 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": {
|
||||||
|
"AUTH_TOKEN": "..."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"env": {
|
|
||||||
"AUTH_TOKEN": "..."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**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:
|
**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:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
|
@ -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
|
||||||
|
@ -114,6 +114,23 @@ 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
|
### Claude Desktop
|
||||||
|
|
||||||
[Official Docs](https://modelcontextprotocol.io/quickstart/user)
|
[Official Docs](https://modelcontextprotocol.io/quickstart/user)
|
||||||
|
|
13
package.json
13
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "mcp-remote",
|
"name": "@kvant/mcp-remote",
|
||||||
"version": "0.1.0-1",
|
"version": "0.1.5",
|
||||||
"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,13 +31,12 @@
|
||||||
"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.10.2",
|
"@modelcontextprotocol/sdk": "^1.11.2",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@types/react": "^19.0.12",
|
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"react": "^19.0.0",
|
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
|
@ -53,8 +52,6 @@
|
||||||
"dts": true,
|
"dts": true,
|
||||||
"clean": true,
|
"clean": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"external": [
|
"external": []
|
||||||
"react"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
|
@ -16,23 +16,17 @@ importers:
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@modelcontextprotocol/sdk':
|
'@modelcontextprotocol/sdk':
|
||||||
specifier: ^1.10.2
|
specifier: ^1.11.2
|
||||||
version: 1.10.2
|
version: 1.11.2
|
||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.0
|
version: 5.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.13.10
|
specifier: ^22.13.10
|
||||||
version: 22.13.10
|
version: 22.13.10
|
||||||
'@types/react':
|
|
||||||
specifier: ^19.0.12
|
|
||||||
version: 19.0.12
|
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.5.3
|
specifier: ^3.5.3
|
||||||
version: 3.5.3
|
version: 3.5.3
|
||||||
react:
|
|
||||||
specifier: ^19.0.0
|
|
||||||
version: 19.0.0
|
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.4.0
|
specifier: ^8.4.0
|
||||||
version: 8.4.0(tsx@4.19.3)(typescript@5.8.2)
|
version: 8.4.0(tsx@4.19.3)(typescript@5.8.2)
|
||||||
|
@ -217,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.10.2':
|
'@modelcontextprotocol/sdk@1.11.2':
|
||||||
resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==}
|
resolution: {integrity: sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
|
@ -350,9 +344,6 @@ packages:
|
||||||
'@types/range-parser@1.2.7':
|
'@types/range-parser@1.2.7':
|
||||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||||
|
|
||||||
'@types/react@19.0.12':
|
|
||||||
resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==}
|
|
||||||
|
|
||||||
'@types/send@0.17.4':
|
'@types/send@0.17.4':
|
||||||
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
|
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
|
||||||
|
|
||||||
|
@ -479,9 +470,6 @@ packages:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
csstype@3.1.3:
|
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -899,10 +887,6 @@ packages:
|
||||||
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
|
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
react@19.0.0:
|
|
||||||
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
readdirp@4.1.2:
|
readdirp@4.1.2:
|
||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
|
@ -1222,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.10.2':
|
'@modelcontextprotocol/sdk@1.11.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
cors: 2.8.5
|
cors: 2.8.5
|
||||||
|
@ -1334,10 +1318,6 @@ snapshots:
|
||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
|
|
||||||
'@types/react@19.0.12':
|
|
||||||
dependencies:
|
|
||||||
csstype: 3.1.3
|
|
||||||
|
|
||||||
'@types/send@0.17.4':
|
'@types/send@0.17.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mime': 1.3.5
|
'@types/mime': 1.3.5
|
||||||
|
@ -1474,8 +1454,6 @@ snapshots:
|
||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.0.0
|
ms: 2.0.0
|
||||||
|
@ -1896,8 +1874,6 @@ snapshots:
|
||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
unpipe: 1.0.0
|
unpipe: 1.0.0
|
||||||
|
|
||||||
react@19.0.0: {}
|
|
||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
resolve-from@5.0.0: {}
|
resolve-from@5.0.0: {}
|
||||||
|
|
|
@ -66,10 +66,10 @@ async function runClient(
|
||||||
// Define an auth initializer function
|
// Define an auth initializer function
|
||||||
const authInitializer = async () => {
|
const authInitializer = async () => {
|
||||||
const authState = await authCoordinator.initializeAuth()
|
const authState = await authCoordinator.initializeAuth()
|
||||||
|
|
||||||
// Store server in outer scope for cleanup
|
// Store server in outer scope for cleanup
|
||||||
server = authState.server
|
server = authState.server
|
||||||
|
|
||||||
// If auth was completed by another instance, just log that we'll use the auth from disk
|
// If auth was completed by another instance, just log that we'll use the auth from disk
|
||||||
if (authState.skipBrowserAuth) {
|
if (authState.skipBrowserAuth) {
|
||||||
log('Authentication was completed by another instance - will use tokens from disk...')
|
log('Authentication was completed by another instance - will use tokens from disk...')
|
||||||
|
@ -77,23 +77,16 @@ async function runClient(
|
||||||
// so we're slightly too early
|
// so we're slightly too early
|
||||||
await new Promise((res) => setTimeout(res, 1_000))
|
await new Promise((res) => setTimeout(res, 1_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
waitForAuthCode: authState.waitForAuthCode,
|
waitForAuthCode: authState.waitForAuthCode,
|
||||||
skipBrowserAuth: authState.skipBrowserAuth
|
skipBrowserAuth: authState.skipBrowserAuth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Connect to remote server with lazy authentication
|
// Connect to remote server with lazy authentication
|
||||||
const transport = await connectToRemoteServer(
|
const transport = await connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy)
|
||||||
client,
|
|
||||||
serverUrl,
|
|
||||||
authProvider,
|
|
||||||
headers,
|
|
||||||
authInitializer,
|
|
||||||
transportStrategy,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Set up message and error handlers
|
// Set up message and error handlers
|
||||||
transport.onmessage = (message) => {
|
transport.onmessage = (message) => {
|
||||||
|
@ -158,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), 3333, 'Usage: npx tsx client.ts <https://server-url> [callback-port]')
|
parseCommandLineArgs(process.argv.slice(2), '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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
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,
|
||||||
OAuthClientInformationSchema,
|
OAuthClientInformationFullSchema,
|
||||||
OAuthTokens,
|
OAuthTokens,
|
||||||
OAuthTokensSchema,
|
OAuthTokensSchema,
|
||||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||||
|
@ -37,7 +36,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
get redirectUrl(): string {
|
get redirectUrl(): string {
|
||||||
return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}`
|
return `http://localhost:${this.options.callbackPort}${this.callbackPath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
get clientMetadata() {
|
get clientMetadata() {
|
||||||
|
@ -57,9 +56,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<OAuthClientInformation | undefined> {
|
async clientInformation(): Promise<OAuthClientInformationFull | undefined> {
|
||||||
// log('Reading client info')
|
// log('Reading client info')
|
||||||
return readJsonFile<OAuthClientInformation>(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema)
|
return readJsonFile<OAuthClientInformationFull>(this.serverUrlHash, 'client_info.json', OAuthClientInformationFullSchema)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
122
src/lib/utils.ts
122
src/lib/utils.ts
|
@ -3,6 +3,13 @@ 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'
|
||||||
|
@ -10,10 +17,6 @@ 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
|
||||||
|
@ -32,14 +35,21 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
|
||||||
let transportToClientClosed = false
|
let transportToClientClosed = false
|
||||||
let transportToServerClosed = false
|
let transportToServerClosed = false
|
||||||
|
|
||||||
transportToClient.onmessage = (message) => {
|
transportToClient.onmessage = (_message) => {
|
||||||
// @ts-expect-error TODO
|
// TODO: fix types
|
||||||
|
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) => {
|
||||||
// @ts-expect-error TODO: fix this type
|
// TODO: fix types
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
@ -93,7 +103,7 @@ export type AuthInitializer = () => Promise<{
|
||||||
* @returns The connected transport
|
* @returns The connected transport
|
||||||
*/
|
*/
|
||||||
export async function connectToRemoteServer(
|
export async function connectToRemoteServer(
|
||||||
client: Client,
|
client: Client | null,
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
authProvider: OAuthClientProvider,
|
authProvider: OAuthClientProvider,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
|
@ -140,7 +150,20 @@ export async function connectToRemoteServer(
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect(transport)
|
if (client) {
|
||||||
|
await client.connect(transport)
|
||||||
|
} else {
|
||||||
|
await transport.start()
|
||||||
|
if (!sseTransport) {
|
||||||
|
// Extremely hacky, but we didn't actually send a request when calling transport.start() above, so we don't
|
||||||
|
// know if we're even talking to an HTTP server. But if we forced that now we'd get an error later saying that
|
||||||
|
// the client is already connected. So let's just create a one-off client to make a single request and figure
|
||||||
|
// out if we're actually talking to an HTTP server or not.
|
||||||
|
const testTransport = new StreamableHTTPClientTransport(url, { authProvider, requestInit: { headers } })
|
||||||
|
const testClient = new Client({ name: 'mcp-remote-fallback-test', version: '0.0.0' }, { capabilities: {} })
|
||||||
|
await testClient.connect(testTransport)
|
||||||
|
}
|
||||||
|
}
|
||||||
log(`Connected to remote server using ${transport.constructor.name}`)
|
log(`Connected to remote server using ${transport.constructor.name}`)
|
||||||
|
|
||||||
return transport
|
return transport
|
||||||
|
@ -149,9 +172,10 @@ export async function connectToRemoteServer(
|
||||||
if (
|
if (
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
shouldAttemptFallback &&
|
shouldAttemptFallback &&
|
||||||
(sseTransport
|
(error.message.includes('405') ||
|
||||||
? error.message.includes('405') || error.message.includes('Method Not Allowed')
|
error.message.includes('Method Not Allowed') ||
|
||||||
: error.message.includes('404') || error.message.includes('Not Found'))
|
error.message.includes('404') ||
|
||||||
|
error.message.includes('Not Found'))
|
||||||
) {
|
) {
|
||||||
log(`Received error: ${error.message}`)
|
log(`Received error: ${error.message}`)
|
||||||
|
|
||||||
|
@ -286,7 +310,16 @@ export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServe
|
||||||
log('Auth code received, resolving promise')
|
log('Auth code received, resolving promise')
|
||||||
authCompletedResolve(code)
|
authCompletedResolve(code)
|
||||||
|
|
||||||
res.send('Authorization successful! You may close this window and return to the CLI.')
|
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>
|
||||||
|
`)
|
||||||
|
|
||||||
// Notify main flow that auth code is available
|
// Notify main flow that auth code is available
|
||||||
options.events.emit('auth-code-received', code)
|
options.events.emit('auth-code-received', code)
|
||||||
|
@ -322,6 +355,27 @@ 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
|
||||||
|
@ -355,15 +409,15 @@ 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[], defaultPort: number, usage: string) {
|
export async function parseCommandLineArgs(args: string[], usage: string) {
|
||||||
// Process headers
|
// Process headers
|
||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {}
|
||||||
args.forEach((arg, i) => {
|
let i = 0
|
||||||
if (arg === '--header' && i < args.length - 1) {
|
while (i < args.length) {
|
||||||
|
if (args[i] === '--header' && i < args.length - 1) {
|
||||||
const value = args[i + 1]
|
const value = args[i + 1]
|
||||||
const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/)
|
const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/)
|
||||||
if (match) {
|
if (match) {
|
||||||
|
@ -372,8 +426,11 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
|
||||||
log(`Warning: ignoring invalid header argument: ${value}`)
|
log(`Warning: ignoring invalid header argument: ${value}`)
|
||||||
}
|
}
|
||||||
args.splice(i, 2)
|
args.splice(i, 2)
|
||||||
|
// Do not increment i, as the array has shifted
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
})
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
const serverUrl = args[0]
|
const serverUrl = args[0]
|
||||||
const specifiedPort = args[1] ? parseInt(args[1]) : undefined
|
const specifiedPort = args[1] ? parseInt(args[1]) : undefined
|
||||||
|
@ -405,14 +462,28 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
|
||||||
log(usage)
|
log(usage)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
const serverUrlHash = getServerUrlHash(serverUrl)
|
||||||
|
const defaultPort = calculateDefaultPort(serverUrlHash)
|
||||||
|
|
||||||
// Use the specified port, or find an available one
|
// Use the specified port, or the existing client port or fallback to find an available one
|
||||||
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort))
|
const [existingClientPort, availablePort] = await Promise.all([findExistingClientPort(serverUrlHash), findAvailablePort(defaultPort)])
|
||||||
|
let callbackPort: number
|
||||||
|
|
||||||
if (specifiedPort) {
|
if (specifiedPort) {
|
||||||
log(`Using specified callback port: ${callbackPort}`)
|
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
|
||||||
} else {
|
} else {
|
||||||
log(`Using automatically selected callback port: ${callbackPort}`)
|
log(`Using automatically selected callback port: ${availablePort}`)
|
||||||
|
callbackPort = availablePort
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(headers).length > 0) {
|
if (Object.keys(headers).length > 0) {
|
||||||
|
@ -450,6 +521,11 @@ 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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
35
src/proxy.ts
35
src/proxy.ts
|
@ -23,12 +23,16 @@ import {
|
||||||
} from './lib/utils'
|
} from './lib/utils'
|
||||||
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
|
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
|
||||||
import { createLazyAuthCoordinator } from './lib/coordination'
|
import { createLazyAuthCoordinator } from './lib/coordination'
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main function to run the proxy
|
* Main function to run the proxy
|
||||||
*/
|
*/
|
||||||
async function runProxy(serverUrl: string, callbackPort: number, headers: Record<string, string>, transportStrategy: TransportStrategy = 'http-first') {
|
async function runProxy(
|
||||||
|
serverUrl: string,
|
||||||
|
callbackPort: number,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
transportStrategy: TransportStrategy = 'http-first',
|
||||||
|
) {
|
||||||
// Set up event emitter for auth flow
|
// Set up event emitter for auth flow
|
||||||
const events = new EventEmitter()
|
const events = new EventEmitter()
|
||||||
|
|
||||||
|
@ -54,10 +58,10 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record
|
||||||
// Define an auth initializer function
|
// Define an auth initializer function
|
||||||
const authInitializer = async () => {
|
const authInitializer = async () => {
|
||||||
const authState = await authCoordinator.initializeAuth()
|
const authState = await authCoordinator.initializeAuth()
|
||||||
|
|
||||||
// Store server in outer scope for cleanup
|
// Store server in outer scope for cleanup
|
||||||
server = authState.server
|
server = authState.server
|
||||||
|
|
||||||
// If auth was completed by another instance, just log that we'll use the auth from disk
|
// If auth was completed by another instance, just log that we'll use the auth from disk
|
||||||
if (authState.skipBrowserAuth) {
|
if (authState.skipBrowserAuth) {
|
||||||
log('Authentication was completed by another instance - will use tokens from disk')
|
log('Authentication was completed by another instance - will use tokens from disk')
|
||||||
|
@ -65,25 +69,16 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record
|
||||||
// so we're slightly too early
|
// so we're slightly too early
|
||||||
await new Promise((res) => setTimeout(res, 1_000))
|
await new Promise((res) => setTimeout(res, 1_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
waitForAuthCode: authState.waitForAuthCode,
|
waitForAuthCode: authState.waitForAuthCode,
|
||||||
skipBrowserAuth: authState.skipBrowserAuth
|
skipBrowserAuth: authState.skipBrowserAuth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = new Client(
|
|
||||||
{
|
|
||||||
name: 'mcp-remote',
|
|
||||||
version: MCP_REMOTE_VERSION,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// Connect to remote server with lazy authentication
|
// Connect to remote server with lazy authentication
|
||||||
const remoteTransport = await connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy)
|
const remoteTransport = await connectToRemoteServer(null, serverUrl, authProvider, headers, authInitializer, transportStrategy)
|
||||||
|
|
||||||
// Set up bidirectional proxy between local and remote transports
|
// Set up bidirectional proxy between local and remote transports
|
||||||
mcpProxy({
|
mcpProxy({
|
||||||
|
@ -94,7 +89,7 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record
|
||||||
// Start the local STDIO server
|
// Start the local STDIO server
|
||||||
await localTransport.start()
|
await localTransport.start()
|
||||||
log('Local STDIO server running')
|
log('Local STDIO server running')
|
||||||
log('Proxy established successfully between local STDIO and remote SSE')
|
log(`Proxy established successfully between local STDIO and remote ${remoteTransport.constructor.name}`)
|
||||||
log('Press Ctrl+C to exit')
|
log('Press Ctrl+C to exit')
|
||||||
|
|
||||||
// Setup cleanup handler
|
// Setup cleanup handler
|
||||||
|
@ -140,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), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]')
|
parseCommandLineArgs(process.argv.slice(2), '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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"types": ["node", "react"],
|
"types": ["node"],
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue