Compare commits
43 commits
streaming-
...
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 | ||
|
504aa26761 | ||
|
b69fdc8ebe | ||
|
bb4c03f069 | ||
|
189ecc9fce | ||
|
a2064502b9 | ||
|
0596122962 | ||
|
7a1bd95844 | ||
|
2913b96e53 | ||
|
3a4d81936c | ||
|
9258e2f6c4 |
11 changed files with 569 additions and 298 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
|
54
README.md
54
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.
|
||||
|
||||
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!
|
||||
|
||||
|
@ -46,15 +46,32 @@ To bypass authentication, or to emit custom headers on all requests to your remo
|
|||
"https://remote.mcp.server/sse",
|
||||
"--header",
|
||||
"Authorization: Bearer ${AUTH_TOKEN}"
|
||||
]
|
||||
],
|
||||
"env": {
|
||||
"AUTH_TOKEN": "..."
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"AUTH_TOKEN": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Cursor and Claude Desktop (Windows) have a bug where spaces inside `args` aren't escaped when it invokes `npx`, which ends up mangling these values. You can work around it using:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// rest of config...
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://remote.mcp.server/sse",
|
||||
"--header",
|
||||
"Authorization:${AUTH_HEADER}" // note no spaces around ':'
|
||||
],
|
||||
"env": {
|
||||
"AUTH_HEADER": "Bearer <auth-token>" // spaces OK in env vars
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
* If `npx` is producing errors, consider adding `-y` as the first argument to auto-accept the installation of the `mcp-remote` package.
|
||||
|
@ -87,6 +104,33 @@ To bypass authentication, or to emit custom headers on all requests to your remo
|
|||
]
|
||||
```
|
||||
|
||||
* To allow HTTP connections in trusted private networks, add the `--allow-http` flag. Note: This should only be used in secure private networks where traffic cannot be intercepted.
|
||||
|
||||
```json
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"http://internal-service.vpc/sse",
|
||||
"--allow-http"
|
||||
]
|
||||
```
|
||||
|
||||
### 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.19",
|
||||
"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.7.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": []
|
||||
}
|
||||
}
|
||||
|
|
191
pnpm-lock.yaml
generated
191
pnpm-lock.yaml
generated
|
@ -8,9 +8,6 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.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.7.0':
|
||||
resolution: {integrity: sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==}
|
||||
'@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==}
|
||||
|
||||
|
@ -860,8 +832,8 @@ packages:
|
|||
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
pkce-challenge@4.1.0:
|
||||
resolution: {integrity: sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==}
|
||||
pkce-challenge@5.0.0:
|
||||
resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
|
||||
postcss-load-config@6.0.1:
|
||||
|
@ -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,17 +1206,18 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@modelcontextprotocol/sdk@1.7.0':
|
||||
'@modelcontextprotocol/sdk@1.11.2':
|
||||
dependencies:
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.5
|
||||
eventsource: 3.0.5
|
||||
express: 5.0.1
|
||||
express-rate-limit: 7.5.0(express@5.0.1)
|
||||
pkce-challenge: 4.1.0
|
||||
cross-spawn: 7.0.6
|
||||
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
|
||||
|
||||
|
@ -1349,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
|
||||
|
@ -1371,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: {}
|
||||
|
@ -1407,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
|
||||
|
||||
|
@ -1489,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
|
||||
|
@ -1574,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:
|
||||
|
@ -1620,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
|
||||
|
@ -1637,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
|
||||
|
@ -1751,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
|
||||
|
@ -1817,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
|
||||
|
||||
|
@ -1831,8 +1781,6 @@ snapshots:
|
|||
|
||||
ms@2.0.0: {}
|
||||
|
||||
ms@2.1.2: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mz@2.7.0:
|
||||
|
@ -1885,7 +1833,7 @@ snapshots:
|
|||
|
||||
pirates@4.0.6: {}
|
||||
|
||||
pkce-challenge@4.1.0: {}
|
||||
pkce-challenge@5.0.0: {}
|
||||
|
||||
postcss-load-config@6.0.1(tsx@4.19.3):
|
||||
dependencies:
|
||||
|
@ -1926,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: {}
|
||||
|
@ -1959,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: {}
|
||||
|
||||
|
@ -1989,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
|
||||
|
@ -2015,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
|
||||
|
||||
|
@ -2161,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: {}
|
||||
|
||||
|
@ -2203,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,15 +1,14 @@
|
|||
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'
|
||||
import type { OAuthProviderOptions } from './types'
|
||||
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile } from './mcp-auth-config'
|
||||
import { getServerUrlHash, log } from './utils'
|
||||
import { getServerUrlHash, log, MCP_REMOTE_VERSION } from './utils'
|
||||
|
||||
/**
|
||||
* Implements the OAuthClientProvider interface for Node.js environments.
|
||||
|
@ -20,6 +19,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
|||
private callbackPath: string
|
||||
private clientName: string
|
||||
private clientUri: string
|
||||
private softwareId: string
|
||||
private softwareVersion: string
|
||||
|
||||
/**
|
||||
* Creates a new NodeOAuthClientProvider
|
||||
|
@ -30,10 +31,12 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
|||
this.callbackPath = options.callbackPath || '/oauth/callback'
|
||||
this.clientName = options.clientName || 'MCP CLI Client'
|
||||
this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli'
|
||||
this.softwareId = options.softwareId || '2e6dc280-f3c3-4e01-99a7-8181dbd1d23d'
|
||||
this.softwareVersion = options.softwareVersion || MCP_REMOTE_VERSION
|
||||
}
|
||||
|
||||
get redirectUrl(): string {
|
||||
return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}`
|
||||
return `http://localhost:${this.options.callbackPort}${this.callbackPath}`
|
||||
}
|
||||
|
||||
get clientMetadata() {
|
||||
|
@ -44,6 +47,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
|||
response_types: ['code'],
|
||||
client_name: this.clientName,
|
||||
client_uri: this.clientUri,
|
||||
software_id: this.softwareId,
|
||||
software_version: this.softwareVersion,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -51,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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -16,6 +16,10 @@ export interface OAuthProviderOptions {
|
|||
clientName?: string
|
||||
/** Client URI to use for OAuth registration */
|
||||
clientUri?: string
|
||||
/** Software ID to use for OAuth registration */
|
||||
softwareId?: string
|
||||
/** Software version to use for OAuth registration */
|
||||
softwareVersion?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
261
src/lib/utils.ts
261
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,49 +84,129 @@ 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)
|
||||
|
||||
// Create transport with eventSourceInit to pass Authorization header if present
|
||||
const eventSourceInit = {
|
||||
fetch: (url: string | URL, init: RequestInit | undefined) => {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
...headers,
|
||||
},
|
||||
})
|
||||
fetch: (url: string | URL, init?: RequestInit) => {
|
||||
return Promise.resolve(authProvider?.tokens?.()).then((tokens) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
...headers,
|
||||
...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}),
|
||||
Accept: 'text/event-stream',
|
||||
} as Record<string, string>,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const transport = new SSEClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
eventSourceInit,
|
||||
})
|
||||
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 {
|
||||
|
@ -121,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
|
||||
|
@ -204,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)
|
||||
|
@ -240,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
|
||||
|
@ -273,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) {
|
||||
|
@ -290,11 +426,28 @@ 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)
|
||||
|
@ -304,18 +457,33 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
|
|||
const url = new URL(serverUrl)
|
||||
const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:'
|
||||
|
||||
if (!(url.protocol == 'https:' || isLocalhost)) {
|
||||
if (!(url.protocol == 'https:' || isLocalhost || allowHttp)) {
|
||||
log('Error: Non-HTTPS URLs are only allowed for localhost or when --allow-http flag is provided')
|
||||
log(usage)
|
||||
process.exit(1)
|
||||
}
|
||||
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) {
|
||||
|
@ -337,7 +505,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
|
|||
})
|
||||
}
|
||||
|
||||
return { serverUrl, callbackPort, headers }
|
||||
return { serverUrl, callbackPort, headers, transportStrategy }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -353,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