Compare commits
33 commits
streamable
...
main
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 |
10 changed files with 519 additions and 292 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
|
35
README.md
35
README.md
|
@ -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 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
|
||||
{
|
||||
|
@ -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,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
|
||||
|
||||
[Official Docs](https://modelcontextprotocol.io/quickstart/user)
|
||||
|
|
13
package.json
13
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "mcp-remote",
|
||||
"version": "0.0.22",
|
||||
"name": "@kvant/mcp-remote",
|
||||
"version": "0.1.5",
|
||||
"description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth",
|
||||
"keywords": [
|
||||
"mcp",
|
||||
|
@ -28,16 +28,15 @@
|
|||
"check": "prettier --check . && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"express": "^4.21.2",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.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"
|
||||
|
@ -53,8 +52,6 @@
|
|||
"dts": true,
|
||||
"clean": true,
|
||||
"outDir": "dist",
|
||||
"external": [
|
||||
"react"
|
||||
]
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
|
|
182
pnpm-lock.yaml
generated
182
pnpm-lock.yaml
generated
|
@ -8,9 +8,6 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
express:
|
||||
specifier: ^4.21.2
|
||||
version: 4.21.2
|
||||
|
@ -18,21 +15,18 @@ importers:
|
|||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
devDependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.11.2
|
||||
version: 1.11.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)
|
||||
|
@ -217,8 +211,8 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.9.0':
|
||||
resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==}
|
||||
'@modelcontextprotocol/sdk@1.11.2':
|
||||
resolution: {integrity: sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
|
@ -350,9 +344,6 @@ 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==}
|
||||
|
||||
|
@ -396,8 +387,8 @@ packages:
|
|||
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
|
||||
body-parser@2.1.0:
|
||||
resolution: {integrity: sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==}
|
||||
body-parser@2.2.0:
|
||||
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
brace-expansion@2.0.1:
|
||||
|
@ -479,9 +470,6 @@ 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:
|
||||
|
@ -490,15 +478,6 @@ packages:
|
|||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.3.6:
|
||||
resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.0:
|
||||
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
|
||||
engines: {node: '>=6.0'}
|
||||
|
@ -576,12 +555,12 @@ packages:
|
|||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
eventsource-parser@3.0.0:
|
||||
resolution: {integrity: sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==}
|
||||
eventsource-parser@3.0.1:
|
||||
resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
eventsource@3.0.5:
|
||||
resolution: {integrity: sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==}
|
||||
eventsource@3.0.6:
|
||||
resolution: {integrity: sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
express-rate-limit@7.5.0:
|
||||
|
@ -594,8 +573,8 @@ packages:
|
|||
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
|
||||
express@5.0.1:
|
||||
resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==}
|
||||
express@5.1.0:
|
||||
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
fdir@6.4.3:
|
||||
|
@ -673,10 +652,6 @@ packages:
|
|||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.5.2:
|
||||
resolution: {integrity: sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
@ -771,8 +746,8 @@ packages:
|
|||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@3.0.0:
|
||||
resolution: {integrity: sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==}
|
||||
mime-types@3.0.1:
|
||||
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime@1.6.0:
|
||||
|
@ -791,9 +766,6 @@ packages:
|
|||
ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
|
||||
ms@2.1.2:
|
||||
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
|
@ -915,10 +887,6 @@ 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'}
|
||||
|
@ -935,8 +903,8 @@ packages:
|
|||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
router@2.1.0:
|
||||
resolution: {integrity: sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==}
|
||||
router@2.2.0:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
run-applescript@7.0.0:
|
||||
|
@ -953,16 +921,16 @@ packages:
|
|||
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
send@1.1.0:
|
||||
resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==}
|
||||
send@1.2.0:
|
||||
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
serve-static@1.16.2:
|
||||
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
serve-static@2.1.0:
|
||||
resolution: {integrity: sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==}
|
||||
serve-static@2.2.0:
|
||||
resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
|
@ -1081,8 +1049,8 @@ packages:
|
|||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
type-is@2.0.0:
|
||||
resolution: {integrity: sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==}
|
||||
type-is@2.0.1:
|
||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typescript@5.8.2:
|
||||
|
@ -1132,8 +1100,8 @@ packages:
|
|||
peerDependencies:
|
||||
zod: ^3.24.1
|
||||
|
||||
zod@3.24.2:
|
||||
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
|
||||
zod@3.24.3:
|
||||
resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
|
@ -1238,18 +1206,18 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@modelcontextprotocol/sdk@1.9.0':
|
||||
'@modelcontextprotocol/sdk@1.11.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)
|
||||
eventsource: 3.0.6
|
||||
express: 5.1.0
|
||||
express-rate-limit: 7.5.0(express@5.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)
|
||||
zod: 3.24.3
|
||||
zod-to-json-schema: 3.24.5(zod@3.24.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
@ -1350,10 +1318,6 @@ 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
|
||||
|
@ -1372,7 +1336,7 @@ snapshots:
|
|||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.0
|
||||
mime-types: 3.0.1
|
||||
negotiator: 1.0.0
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
@ -1408,17 +1372,17 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
body-parser@2.1.0:
|
||||
body-parser@2.2.0:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.0
|
||||
http-errors: 2.0.0
|
||||
iconv-lite: 0.5.2
|
||||
iconv-lite: 0.6.3
|
||||
on-finished: 2.4.1
|
||||
qs: 6.14.0
|
||||
raw-body: 3.0.0
|
||||
type-is: 2.0.0
|
||||
type-is: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
@ -1490,16 +1454,10 @@ snapshots:
|
|||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
debug@2.6.9:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
|
||||
debug@4.3.6:
|
||||
dependencies:
|
||||
ms: 2.1.2
|
||||
|
||||
debug@4.4.0:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
@ -1575,15 +1533,15 @@ snapshots:
|
|||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eventsource-parser@3.0.0: {}
|
||||
eventsource-parser@3.0.1: {}
|
||||
|
||||
eventsource@3.0.5:
|
||||
eventsource@3.0.6:
|
||||
dependencies:
|
||||
eventsource-parser: 3.0.0
|
||||
eventsource-parser: 3.0.1
|
||||
|
||||
express-rate-limit@7.5.0(express@5.0.1):
|
||||
express-rate-limit@7.5.0(express@5.1.0):
|
||||
dependencies:
|
||||
express: 5.0.1
|
||||
express: 5.1.0
|
||||
|
||||
express@4.21.2:
|
||||
dependencies:
|
||||
|
@ -1621,16 +1579,15 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
express@5.0.1:
|
||||
express@5.1.0:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.1.0
|
||||
body-parser: 2.2.0
|
||||
content-disposition: 1.0.0
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.1
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.3.6
|
||||
depd: 2.0.0
|
||||
debug: 4.4.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
|
@ -1638,22 +1595,18 @@ snapshots:
|
|||
fresh: 2.0.0
|
||||
http-errors: 2.0.0
|
||||
merge-descriptors: 2.0.0
|
||||
methods: 1.1.2
|
||||
mime-types: 3.0.0
|
||||
mime-types: 3.0.1
|
||||
on-finished: 2.4.1
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.13.0
|
||||
qs: 6.14.0
|
||||
range-parser: 1.2.1
|
||||
router: 2.1.0
|
||||
safe-buffer: 5.2.1
|
||||
send: 1.1.0
|
||||
serve-static: 2.1.0
|
||||
setprototypeof: 1.2.0
|
||||
router: 2.2.0
|
||||
send: 1.2.0
|
||||
serve-static: 2.2.0
|
||||
statuses: 2.0.1
|
||||
type-is: 2.0.0
|
||||
utils-merge: 1.0.1
|
||||
type-is: 2.0.1
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
@ -1752,10 +1705,6 @@ snapshots:
|
|||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.5.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
@ -1818,7 +1767,7 @@ snapshots:
|
|||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime-types@3.0.0:
|
||||
mime-types@3.0.1:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
|
@ -1832,8 +1781,6 @@ snapshots:
|
|||
|
||||
ms@2.0.0: {}
|
||||
|
||||
ms@2.1.2: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mz@2.7.0:
|
||||
|
@ -1927,8 +1874,6 @@ snapshots:
|
|||
iconv-lite: 0.6.3
|
||||
unpipe: 1.0.0
|
||||
|
||||
react@19.0.0: {}
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
|
@ -1960,11 +1905,15 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc': 4.35.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
router@2.1.0:
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.0
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 8.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
run-applescript@7.0.0: {}
|
||||
|
||||
|
@ -1990,16 +1939,15 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
send@1.1.0:
|
||||
send@1.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.0
|
||||
destroy: 1.2.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
fresh: 0.5.2
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.0
|
||||
mime-types: 2.1.35
|
||||
mime-types: 3.0.1
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.2.1
|
||||
|
@ -2016,12 +1964,12 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
serve-static@2.1.0:
|
||||
serve-static@2.2.0:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 1.1.0
|
||||
send: 1.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
@ -2162,11 +2110,11 @@ snapshots:
|
|||
media-typer: 0.3.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
type-is@2.0.0:
|
||||
type-is@2.0.1:
|
||||
dependencies:
|
||||
content-type: 1.0.5
|
||||
media-typer: 1.1.0
|
||||
mime-types: 3.0.0
|
||||
mime-types: 3.0.1
|
||||
|
||||
typescript@5.8.2: {}
|
||||
|
||||
|
@ -2204,8 +2152,8 @@ snapshots:
|
|||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
zod-to-json-schema@3.24.5(zod@3.24.2):
|
||||
zod-to-json-schema@3.24.5(zod@3.24.3):
|
||||
dependencies:
|
||||
zod: 3.24.2
|
||||
zod: 3.24.3
|
||||
|
||||
zod@3.24.2: {}
|
||||
zod@3.24.3: {}
|
||||
|
|
180
src/client.ts
180
src/client.ts
|
@ -11,25 +11,36 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.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'
|
||||
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
|
||||
import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, getServerUrlHash } from './lib/utils'
|
||||
import { coordinateAuth } from './lib/coordination'
|
||||
import {
|
||||
parseCommandLineArgs,
|
||||
setupSignalHandlers,
|
||||
log,
|
||||
MCP_REMOTE_VERSION,
|
||||
getServerUrlHash,
|
||||
connectToRemoteServer,
|
||||
TransportStrategy,
|
||||
} from './lib/utils'
|
||||
import { createLazyAuthCoordinator } 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>,
|
||||
transportStrategy: TransportStrategy = 'http-first',
|
||||
) {
|
||||
// Set up event emitter for auth flow
|
||||
const events = new EventEmitter()
|
||||
|
||||
// Get the server URL hash for lockfile operations
|
||||
const serverUrlHash = getServerUrlHash(serverUrl)
|
||||
|
||||
// Coordinate authentication with other instances
|
||||
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events)
|
||||
// Create a lazy auth coordinator
|
||||
const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events)
|
||||
|
||||
// Create the OAuth client provider
|
||||
const authProvider = new NodeOAuthClientProvider({
|
||||
|
@ -38,14 +49,6 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor
|
|||
clientName: 'MCP CLI Client',
|
||||
})
|
||||
|
||||
// If auth was completed by another instance, just log that we'll use the auth from disk
|
||||
if (skipBrowserAuth) {
|
||||
log('Authentication was completed by another instance - will use tokens from disk...')
|
||||
// TODO: remove, the callback is happening before the tokens are exchanged
|
||||
// so we're slightly too early
|
||||
await new Promise((res) => setTimeout(res, 1_000))
|
||||
}
|
||||
|
||||
// Create the client
|
||||
const client = new Client(
|
||||
{
|
||||
|
@ -57,10 +60,33 @@ 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 } })
|
||||
// Keep track of the server instance for cleanup
|
||||
let server: any = null
|
||||
|
||||
// Define an auth initializer function
|
||||
const authInitializer = async () => {
|
||||
const authState = await authCoordinator.initializeAuth()
|
||||
|
||||
// Store server in outer scope for cleanup
|
||||
server = authState.server
|
||||
|
||||
// If auth was completed by another instance, just log that we'll use the auth from disk
|
||||
if (authState.skipBrowserAuth) {
|
||||
log('Authentication was completed by another instance - will use tokens from disk...')
|
||||
// TODO: remove, the callback is happening before the tokens are exchanged
|
||||
// so we're slightly too early
|
||||
await new Promise((res) => setTimeout(res, 1_000))
|
||||
}
|
||||
|
||||
return {
|
||||
waitForAuthCode: authState.waitForAuthCode,
|
||||
skipBrowserAuth: authState.skipBrowserAuth,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect to remote server with lazy authentication
|
||||
const transport = await connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy)
|
||||
|
||||
// Set up message and error handlers
|
||||
transport.onmessage = (message) => {
|
||||
|
@ -75,89 +101,59 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor
|
|||
log('Connection closed.')
|
||||
process.exit(0)
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
const transport = initTransport()
|
||||
|
||||
// Set up cleanup handler
|
||||
const cleanup = async () => {
|
||||
log('\nClosing connection...')
|
||||
await client.close()
|
||||
server.close()
|
||||
}
|
||||
setupSignalHandlers(cleanup)
|
||||
|
||||
// Try to connect
|
||||
try {
|
||||
log('Connecting to server...')
|
||||
await client.connect(transport)
|
||||
log('Connected successfully!')
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
||||
log('Authentication required. Waiting for authorization...')
|
||||
|
||||
// Wait for the authorization code from the callback or another instance
|
||||
const code = await waitForAuthCode()
|
||||
|
||||
try {
|
||||
log('Completing authorization...')
|
||||
await transport.finishAuth(code)
|
||||
|
||||
// Reconnect after authorization with a new transport
|
||||
log('Connecting after authorization...')
|
||||
await client.connect(initTransport())
|
||||
|
||||
log('Connected successfully!')
|
||||
|
||||
// Request tools list after auth
|
||||
log('Requesting tools list...')
|
||||
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||
log('Tools:', JSON.stringify(tools, null, 2))
|
||||
|
||||
// Request resources list after auth
|
||||
log('Requesting resource list...')
|
||||
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
||||
log('Resources:', JSON.stringify(resources, null, 2))
|
||||
|
||||
log('Listening for messages. Press Ctrl+C to exit.')
|
||||
} catch (authError) {
|
||||
log('Authorization error:', authError)
|
||||
// Set up cleanup handler
|
||||
const cleanup = async () => {
|
||||
log('\nClosing connection...')
|
||||
await client.close()
|
||||
// If auth was initialized and server was created, close it
|
||||
if (server) {
|
||||
server.close()
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
log('Connection error:', error)
|
||||
server.close()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
setupSignalHandlers(cleanup)
|
||||
|
||||
try {
|
||||
// Request tools list
|
||||
log('Requesting tools list...')
|
||||
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||
log('Tools:', JSON.stringify(tools, null, 2))
|
||||
} catch (e) {
|
||||
log('Error requesting tools list:', e)
|
||||
}
|
||||
log('Connected successfully!')
|
||||
|
||||
try {
|
||||
// Request resources list
|
||||
log('Requesting resource list...')
|
||||
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
||||
log('Resources:', JSON.stringify(resources, null, 2))
|
||||
} catch (e) {
|
||||
log('Error requesting resources list:', e)
|
||||
}
|
||||
try {
|
||||
// Request tools list
|
||||
log('Requesting tools list...')
|
||||
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||
log('Tools:', JSON.stringify(tools, null, 2))
|
||||
} catch (e) {
|
||||
log('Error requesting tools list:', e)
|
||||
}
|
||||
|
||||
log('Listening for messages. Press Ctrl+C to exit.')
|
||||
try {
|
||||
// Request resources list
|
||||
log('Requesting resource list...')
|
||||
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
||||
log('Resources:', JSON.stringify(resources, null, 2))
|
||||
} catch (e) {
|
||||
log('Error requesting resources list:', e)
|
||||
}
|
||||
|
||||
// log('Listening for messages. Press Ctrl+C to exit.')
|
||||
log('Exiting OK...')
|
||||
// Only close the server if it was initialized
|
||||
if (server) {
|
||||
server.close()
|
||||
}
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
log('Fatal error:', error)
|
||||
// Only close the server if it was initialized
|
||||
if (server) {
|
||||
server.close()
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 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), 'Usage: npx tsx client.ts <https://server-url> [callback-port]')
|
||||
.then(({ serverUrl, callbackPort, headers, transportStrategy }) => {
|
||||
return runClient(serverUrl, callbackPort, headers, transportStrategy)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Fatal error:', error)
|
||||
|
|
|
@ -5,6 +5,10 @@ import express from 'express'
|
|||
import { AddressInfo } from 'net'
|
||||
import { log, setupOAuthCallbackServerWithLongPoll } from './utils'
|
||||
|
||||
export type AuthCoordinator = {
|
||||
initializeAuth: () => Promise<{ server: Server; waitForAuthCode: () => Promise<string>; skipBrowserAuth: boolean }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a process with the given PID is running
|
||||
* @param pid The process ID to check
|
||||
|
@ -88,6 +92,36 @@ export async function waitForAuthentication(port: number): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lazy auth coordinator that will only initiate auth when needed
|
||||
* @param serverUrlHash The hash of the server URL
|
||||
* @param callbackPort The port to use for the callback server
|
||||
* @param events The event emitter to use for signaling
|
||||
* @returns An AuthCoordinator object with an initializeAuth method
|
||||
*/
|
||||
export function createLazyAuthCoordinator(
|
||||
serverUrlHash: string,
|
||||
callbackPort: number,
|
||||
events: EventEmitter
|
||||
): AuthCoordinator {
|
||||
let authState: { server: Server; waitForAuthCode: () => Promise<string>; skipBrowserAuth: boolean } | null = null
|
||||
|
||||
return {
|
||||
initializeAuth: async () => {
|
||||
// If auth has already been initialized, return the existing state
|
||||
if (authState) {
|
||||
return authState
|
||||
}
|
||||
|
||||
log('Initializing auth coordination on-demand')
|
||||
|
||||
// Initialize auth using the existing coordinateAuth logic
|
||||
authState = await coordinateAuth(serverUrlHash, callbackPort, events)
|
||||
return authState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates authentication between multiple instances of the client/proxy
|
||||
* @param serverUrlHash The hash of the server URL
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import open from 'open'
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientInformationSchema,
|
||||
OAuthClientInformationFullSchema,
|
||||
OAuthTokens,
|
||||
OAuthTokensSchema,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
|
@ -37,7 +36,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
|||
}
|
||||
|
||||
get redirectUrl(): string {
|
||||
return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}`
|
||||
return `http://localhost:${this.options.callbackPort}${this.callbackPath}`
|
||||
}
|
||||
|
||||
get clientMetadata() {
|
||||
|
@ -57,9 +56,9 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
|||
* Gets the client information if it exists
|
||||
* @returns The client information or undefined
|
||||
*/
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
async clientInformation(): Promise<OAuthClientInformationFull | undefined> {
|
||||
// log('Reading client info')
|
||||
return readJsonFile<OAuthClientInformation>(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema)
|
||||
return readJsonFile<OAuthClientInformationFull>(this.serverUrlHash, 'client_info.json', OAuthClientInformationFullSchema)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
245
src/lib/utils.ts
245
src/lib/utils.ts
|
@ -1,10 +1,22 @@
|
|||
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
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'
|
||||
export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport'
|
||||
|
||||
// Transport strategy types
|
||||
export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first'
|
||||
|
||||
// Package version from package.json
|
||||
export const MCP_REMOTE_VERSION = require('../../package.json').version
|
||||
|
@ -23,14 +35,21 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
|
|||
let transportToClientClosed = false
|
||||
let transportToServerClosed = false
|
||||
|
||||
transportToClient.onmessage = (message) => {
|
||||
// @ts-expect-error TODO
|
||||
transportToClient.onmessage = (_message) => {
|
||||
// TODO: fix types
|
||||
const message = _message as any
|
||||
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) => {
|
||||
// @ts-expect-error TODO: fix this type
|
||||
transportToServer.onmessage = (_message) => {
|
||||
// TODO: fix types
|
||||
const message = _message as any
|
||||
log('[Remote→Local]', message.method || message.id)
|
||||
transportToClient.send(message).catch(onClientError)
|
||||
}
|
||||
|
@ -65,21 +84,33 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
|
|||
}
|
||||
|
||||
/**
|
||||
* Creates and connects to a remote SSE server with OAuth authentication
|
||||
* Type for the auth initialization function
|
||||
*/
|
||||
export type AuthInitializer = () => Promise<{
|
||||
waitForAuthCode: () => Promise<string>
|
||||
skipBrowserAuth: boolean
|
||||
}>
|
||||
|
||||
/**
|
||||
* Creates and connects to a remote server with OAuth authentication
|
||||
* @param client The client to connect with
|
||||
* @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 authInitializer Function to initialize authentication when needed
|
||||
* @param transportStrategy Strategy for selecting transport type ('sse-only', 'http-only', 'sse-first', 'http-first')
|
||||
* @param recursionReasons Set of reasons for recursive calls (internal use)
|
||||
* @returns The connected transport
|
||||
*/
|
||||
export async function connectToRemoteServer(
|
||||
client: Client | null,
|
||||
serverUrl: string,
|
||||
authProvider: OAuthClientProvider,
|
||||
headers: Record<string, string>,
|
||||
waitForAuthCode: () => Promise<string>,
|
||||
skipBrowserAuth: boolean = false,
|
||||
): Promise<SSEClientTransport> {
|
||||
authInitializer: AuthInitializer,
|
||||
transportStrategy: TransportStrategy = 'http-first',
|
||||
recursionReasons: Set<string> = new Set(),
|
||||
): Promise<Transport> {
|
||||
log(`[${pid}] Connecting to remote server: ${serverUrl}`)
|
||||
const url = new URL(serverUrl)
|
||||
|
||||
|
@ -93,25 +124,89 @@ export async function connectToRemoteServer(
|
|||
...(init?.headers as Record<string, string> | undefined),
|
||||
...headers,
|
||||
...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}),
|
||||
Accept: "text/event-stream",
|
||||
Accept: 'text/event-stream',
|
||||
} as Record<string, string>,
|
||||
})
|
||||
);
|
||||
}),
|
||||
)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const transport = new SSEClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
eventSourceInit,
|
||||
})
|
||||
log(`Using transport strategy: ${transportStrategy}`)
|
||||
// Determine if we should attempt to fallback on error
|
||||
// Choose transport based on user strategy and recursion history
|
||||
const shouldAttemptFallback = transportStrategy === 'http-first' || transportStrategy === 'sse-first'
|
||||
|
||||
// Create transport instance based on the strategy
|
||||
const sseTransport = transportStrategy === 'sse-only' || transportStrategy === 'sse-first'
|
||||
const transport = sseTransport
|
||||
? new SSEClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
eventSourceInit,
|
||||
})
|
||||
: new StreamableHTTPClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
})
|
||||
|
||||
try {
|
||||
await transport.start()
|
||||
log('Connected to remote server')
|
||||
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}`)
|
||||
|
||||
return transport
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
||||
// Check if it's a protocol error and we should attempt fallback
|
||||
if (
|
||||
error instanceof Error &&
|
||||
shouldAttemptFallback &&
|
||||
(error.message.includes('405') ||
|
||||
error.message.includes('Method Not Allowed') ||
|
||||
error.message.includes('404') ||
|
||||
error.message.includes('Not Found'))
|
||||
) {
|
||||
log(`Received error: ${error.message}`)
|
||||
|
||||
// If we've already tried falling back once, throw an error
|
||||
if (recursionReasons.has(REASON_TRANSPORT_FALLBACK)) {
|
||||
const errorMessage = `Already attempted transport fallback. Giving up.`
|
||||
log(errorMessage)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
log(`Recursively reconnecting for reason: ${REASON_TRANSPORT_FALLBACK}`)
|
||||
|
||||
// Add to recursion reasons set
|
||||
recursionReasons.add(REASON_TRANSPORT_FALLBACK)
|
||||
|
||||
// Recursively call connectToRemoteServer with the updated recursion tracking
|
||||
return connectToRemoteServer(
|
||||
client,
|
||||
serverUrl,
|
||||
authProvider,
|
||||
headers,
|
||||
authInitializer,
|
||||
sseTransport ? 'http-only' : 'sse-only',
|
||||
recursionReasons,
|
||||
)
|
||||
} else if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
||||
log('Authentication required. Initializing auth...')
|
||||
|
||||
// Initialize authentication on-demand
|
||||
const { waitForAuthCode, skipBrowserAuth } = await authInitializer()
|
||||
|
||||
if (skipBrowserAuth) {
|
||||
log('Authentication required but skipping browser auth - using shared auth')
|
||||
} else {
|
||||
|
@ -125,11 +220,18 @@ 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 } })
|
||||
await newTransport.start()
|
||||
log('Connected to remote server after authentication')
|
||||
return newTransport
|
||||
if (recursionReasons.has(REASON_AUTH_NEEDED)) {
|
||||
const errorMessage = `Already attempted reconnection for reason: ${REASON_AUTH_NEEDED}. Giving up.`
|
||||
log(errorMessage)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
// Track this reason for recursion
|
||||
recursionReasons.add(REASON_AUTH_NEEDED)
|
||||
log(`Recursively reconnecting for reason: ${REASON_AUTH_NEEDED}`)
|
||||
|
||||
// Recursively call connectToRemoteServer with the updated recursion tracking
|
||||
return connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy, recursionReasons)
|
||||
} catch (authError) {
|
||||
log('Authorization error:', authError)
|
||||
throw authError
|
||||
|
@ -208,7 +310,16 @@ 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.')
|
||||
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
|
||||
options.events.emit('auth-code-received', code)
|
||||
|
@ -244,6 +355,27 @@ 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
|
||||
|
@ -277,15 +409,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[], defaultPort: number, usage: string) {
|
||||
export async function parseCommandLineArgs(args: string[], usage: string) {
|
||||
// Process headers
|
||||
const headers: Record<string, string> = {}
|
||||
args.forEach((arg, i) => {
|
||||
if (arg === '--header' && i < args.length - 1) {
|
||||
let i = 0
|
||||
while (i < args.length) {
|
||||
if (args[i] === '--header' && i < args.length - 1) {
|
||||
const value = args[i + 1]
|
||||
const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/)
|
||||
if (match) {
|
||||
|
@ -294,13 +426,29 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
|
|||
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
|
||||
const allowHttp = args.includes('--allow-http')
|
||||
|
||||
// Parse transport strategy
|
||||
let transportStrategy: TransportStrategy = 'http-first' // Default
|
||||
const transportIndex = args.indexOf('--transport')
|
||||
if (transportIndex !== -1 && transportIndex < args.length - 1) {
|
||||
const strategy = args[transportIndex + 1]
|
||||
if (strategy === 'sse-only' || strategy === 'http-only' || strategy === 'sse-first' || strategy === 'http-first') {
|
||||
transportStrategy = strategy as TransportStrategy
|
||||
log(`Using transport strategy: ${transportStrategy}`)
|
||||
} else {
|
||||
log(`Warning: Ignoring invalid transport strategy: ${strategy}. Valid values are: sse-only, http-only, sse-first, http-first`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverUrl) {
|
||||
log(usage)
|
||||
process.exit(1)
|
||||
|
@ -314,14 +462,28 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
|
|||
log(usage)
|
||||
process.exit(1)
|
||||
}
|
||||
const serverUrlHash = getServerUrlHash(serverUrl)
|
||||
const defaultPort = calculateDefaultPort(serverUrlHash)
|
||||
|
||||
// Use the specified port, or find an available one
|
||||
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort))
|
||||
// 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
|
||||
|
||||
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 {
|
||||
log(`Using automatically selected callback port: ${callbackPort}`)
|
||||
log(`Using automatically selected callback port: ${availablePort}`)
|
||||
callbackPort = availablePort
|
||||
}
|
||||
|
||||
if (Object.keys(headers).length > 0) {
|
||||
|
@ -343,7 +505,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
|
|||
})
|
||||
}
|
||||
|
||||
return { serverUrl, callbackPort, headers }
|
||||
return { serverUrl, callbackPort, headers, transportStrategy }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -359,6 +521,11 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
78
src/proxy.ts
78
src/proxy.ts
|
@ -11,22 +11,36 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash } from './lib/utils'
|
||||
import {
|
||||
connectToRemoteServer,
|
||||
log,
|
||||
mcpProxy,
|
||||
parseCommandLineArgs,
|
||||
setupSignalHandlers,
|
||||
getServerUrlHash,
|
||||
MCP_REMOTE_VERSION,
|
||||
TransportStrategy,
|
||||
} from './lib/utils'
|
||||
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
|
||||
import { coordinateAuth } from './lib/coordination'
|
||||
import { createLazyAuthCoordinator } 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>,
|
||||
transportStrategy: TransportStrategy = 'http-first',
|
||||
) {
|
||||
// Set up event emitter for auth flow
|
||||
const events = new EventEmitter()
|
||||
|
||||
// Get the server URL hash for lockfile operations
|
||||
const serverUrlHash = getServerUrlHash(serverUrl)
|
||||
|
||||
// Coordinate authentication with other instances
|
||||
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events)
|
||||
// Create a lazy auth coordinator
|
||||
const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events)
|
||||
|
||||
// Create the OAuth client provider
|
||||
const authProvider = new NodeOAuthClientProvider({
|
||||
|
@ -35,20 +49,36 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record
|
|||
clientName: 'MCP CLI Proxy',
|
||||
})
|
||||
|
||||
// If auth was completed by another instance, just log that we'll use the auth from disk
|
||||
if (skipBrowserAuth) {
|
||||
log('Authentication was completed by another instance - will use tokens from disk')
|
||||
// TODO: remove, the callback is happening before the tokens are exchanged
|
||||
// so we're slightly too early
|
||||
await new Promise((res) => setTimeout(res, 1_000))
|
||||
}
|
||||
|
||||
// Create the STDIO transport for local connections
|
||||
const localTransport = new StdioServerTransport()
|
||||
|
||||
// Keep track of the server instance for cleanup
|
||||
let server: any = null
|
||||
|
||||
// Define an auth initializer function
|
||||
const authInitializer = async () => {
|
||||
const authState = await authCoordinator.initializeAuth()
|
||||
|
||||
// Store server in outer scope for cleanup
|
||||
server = authState.server
|
||||
|
||||
// If auth was completed by another instance, just log that we'll use the auth from disk
|
||||
if (authState.skipBrowserAuth) {
|
||||
log('Authentication was completed by another instance - will use tokens from disk')
|
||||
// TODO: remove, the callback is happening before the tokens are exchanged
|
||||
// so we're slightly too early
|
||||
await new Promise((res) => setTimeout(res, 1_000))
|
||||
}
|
||||
|
||||
return {
|
||||
waitForAuthCode: authState.waitForAuthCode,
|
||||
skipBrowserAuth: authState.skipBrowserAuth,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect to remote server with authentication
|
||||
const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, headers, waitForAuthCode, skipBrowserAuth)
|
||||
// Connect to remote server with lazy authentication
|
||||
const remoteTransport = await connectToRemoteServer(null, serverUrl, authProvider, headers, authInitializer, transportStrategy)
|
||||
|
||||
// Set up bidirectional proxy between local and remote transports
|
||||
mcpProxy({
|
||||
|
@ -59,14 +89,17 @@ 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 ${remoteTransport.constructor.name}`)
|
||||
log('Press Ctrl+C to exit')
|
||||
|
||||
// Setup cleanup handler
|
||||
const cleanup = async () => {
|
||||
await remoteTransport.close()
|
||||
await localTransport.close()
|
||||
server.close()
|
||||
// Only close the server if it was initialized
|
||||
if (server) {
|
||||
server.close()
|
||||
}
|
||||
}
|
||||
setupSignalHandlers(cleanup)
|
||||
} catch (error) {
|
||||
|
@ -93,15 +126,18 @@ to the CA certificate file. If using claude_desktop_config.json, this might look
|
|||
}
|
||||
`)
|
||||
}
|
||||
server.close()
|
||||
// Only close the server if it was initialized
|
||||
if (server) {
|
||||
server.close()
|
||||
}
|
||||
process.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]')
|
||||
.then(({ serverUrl, callbackPort, headers }) => {
|
||||
return runProxy(serverUrl, callbackPort, headers)
|
||||
parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]')
|
||||
.then(({ serverUrl, callbackPort, headers, transportStrategy }) => {
|
||||
return runProxy(serverUrl, callbackPort, headers, transportStrategy)
|
||||
})
|
||||
.catch((error) => {
|
||||
log('Fatal error:', error)
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node", "react"],
|
||||
"types": ["node"],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue