Compare commits
64 commits
Author | SHA1 | Date | |
---|---|---|---|
d1cb48f770 | |||
a63b93aa5c | |||
d8ce274506 | |||
a7a76d3f17 | |||
27907a4624 | |||
0213c20d3d | |||
4f6de14fbc | |||
675dc6a760 | |||
8f83b18966 | |||
|
7eecc9ca3f | ||
|
5199279ea7 | ||
|
b1dfa9fe5b | ||
|
6f2399bbfb | ||
|
e5cdf08bc8 | ||
|
bd6df4222f | ||
|
b209d98074 | ||
|
bd75a1cdf0 | ||
|
767549412f | ||
|
46e3333416 | ||
|
63e02eef1c | ||
|
45c1739b4c | ||
|
5c71b26869 | ||
|
b9105958c1 | ||
|
114ee3c4b6 | ||
|
c9e082d9e2 | ||
|
67bd63192f | ||
|
c4a2d4a242 | ||
|
026caedd3c | ||
|
da1330d2aa | ||
|
15f9c944f6 | ||
|
2b2b12decd | ||
|
5a38b58f63 | ||
|
04e3d255b1 | ||
|
504aa26761 | ||
|
b69fdc8ebe | ||
|
bb4c03f069 | ||
|
189ecc9fce | ||
|
a2064502b9 | ||
|
0596122962 | ||
|
7a1bd95844 | ||
|
2913b96e53 | ||
|
3a4d81936c | ||
|
9258e2f6c4 | ||
|
89177a5ac5 | ||
|
c542f78288 | ||
|
14da1b44d4 | ||
|
327b4f7b0e | ||
|
3c4600a920 | ||
|
cf459b1a0d | ||
|
3225345bb4 | ||
|
fcf9ed8583 | ||
|
a3b4906afd | ||
|
84b87375fb | ||
|
d125df49a2 | ||
|
684320a04c | ||
|
88fa18b1d1 | ||
|
c6f98ff4b7 | ||
|
bd3610d87b | ||
|
7eac41c561 | ||
|
03e56ea31b | ||
|
d5f5b31786 | ||
|
9fbba0509c | ||
|
412b5d9486 | ||
|
743b6b207f |
12 changed files with 980 additions and 370 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
|
94
README.md
94
README.md
|
@ -10,7 +10,7 @@ So far, the majority of MCP servers in the wild are installed locally, using the
|
||||||
|
|
||||||
But there's a reason most software that _could_ be moved to the web _did_ get moved to the web: it's so much easier to find and fix bugs & iterate on new features when you can push updates to all your users with a single deploy.
|
But there's a reason most software that _could_ be moved to the web _did_ get moved to the web: it's so much easier to find and fix bugs & iterate on new features when you can push updates to all your users with a single deploy.
|
||||||
|
|
||||||
With the MCP [Authorization specification](https://spec.modelcontextprotocol.io/specification/draft/basic/authorization/) nearing completion, we now have a secure way of sharing our MCP servers with the world _without_ running code on user's laptops. Or at least, you would, if all the popular MCP _clients_ supported it yet. Most are stdio-only, and those that _do_ support HTTP+SSE don't yet support the OAuth flows required.
|
With the latest MCP [Authorization specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization), we now have a secure way of sharing our MCP servers with the world _without_ running code on user's laptops. Or at least, you would, if all the popular MCP _clients_ supported it yet. Most are stdio-only, and those that _do_ support HTTP+SSE don't yet support the OAuth flows required.
|
||||||
|
|
||||||
That's where `mcp-remote` comes in. As soon as your chosen MCP client supports remote, authorized servers, you can remove it. Until that time, drop in this one liner and dress for the MCP clients you want!
|
That's where `mcp-remote` comes in. As soon as your chosen MCP client supports remote, authorized servers, you can remove it. Until that time, drop in this one liner and dress for the MCP clients you want!
|
||||||
|
|
||||||
|
@ -32,6 +32,46 @@ All the most popular MCP clients (Claude Desktop, Cursor & Windsurf) use the fol
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Custom Headers
|
||||||
|
|
||||||
|
To bypass authentication, or to emit custom headers on all requests to your remote server, pass `--header` CLI arguments:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"remote-example": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"mcp-remote",
|
||||||
|
"https://remote.mcp.server/sse",
|
||||||
|
"--header",
|
||||||
|
"Authorization: Bearer ${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
|
### Flags
|
||||||
|
|
||||||
* If `npx` is producing errors, consider adding `-y` as the first argument to auto-accept the installation of the `mcp-remote` package.
|
* If `npx` is producing errors, consider adding `-y` as the first argument to auto-accept the installation of the `mcp-remote` package.
|
||||||
|
@ -54,16 +94,6 @@ All the most popular MCP clients (Claude Desktop, Cursor & Windsurf) use the fol
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
* To force `mcp-remote` to ignore any existing access tokens and begin the authorization flow anew, pass `--clean`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
"args": [
|
|
||||||
"mcp-remote",
|
|
||||||
"https://remote.mcp.server/sse",
|
|
||||||
"--clean"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
* To change which port `mcp-remote` listens for an OAuth redirect (by default `3334`), add an additional argument after the server URL. Note that whatever port you specify, if it is unavailable an open port will be chosen at random.
|
* To change which port `mcp-remote` listens for an OAuth redirect (by default `3334`), add an additional argument after the server URL. Note that whatever port you specify, if it is unavailable an open port will be chosen at random.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
@ -74,6 +104,33 @@ All the most popular MCP clients (Claude Desktop, Cursor & Windsurf) use the fol
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
* 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
|
### Claude Desktop
|
||||||
|
|
||||||
[Official Docs](https://modelcontextprotocol.io/quickstart/user)
|
[Official Docs](https://modelcontextprotocol.io/quickstart/user)
|
||||||
|
@ -130,7 +187,7 @@ Then restarting your MCP client.
|
||||||
|
|
||||||
### Check your Node version
|
### Check your Node version
|
||||||
|
|
||||||
Make sure that the version of Node you have installed is [16 or
|
Make sure that the version of Node you have installed is [18 or
|
||||||
higher](https://modelcontextprotocol.io/quickstart/server). Claude
|
higher](https://modelcontextprotocol.io/quickstart/server). Claude
|
||||||
Desktop will use your system version of Node, even if you have a newer
|
Desktop will use your system version of Node, even if you have a newer
|
||||||
version installed elsewhere.
|
version installed elsewhere.
|
||||||
|
@ -169,6 +226,17 @@ this might look like:
|
||||||
* For bash on WSL:<br/>`tail -n 20 -f "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log"`
|
* For bash on WSL:<br/>`tail -n 20 -f "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log"`
|
||||||
* Powershell: <br/>`Get-Content "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log" -Wait -Tail 20`
|
* Powershell: <br/>`Get-Content "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log" -Wait -Tail 20`
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
If you encounter the following error, returned by the `/callback` URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authentication Error
|
||||||
|
Token exchange failed: HTTP 400
|
||||||
|
```
|
||||||
|
|
||||||
|
You can run `rm -rf ~/.mcp-auth` to clear any locally stored state and tokens.
|
||||||
|
|
||||||
### "Client" mode
|
### "Client" mode
|
||||||
|
|
||||||
Run the following on the command line (not from an MCP server):
|
Run the following on the command line (not from an MCP server):
|
||||||
|
@ -177,4 +245,4 @@ Run the following on the command line (not from an MCP server):
|
||||||
npx -p mcp-remote@latest mcp-remote-client https://remote.mcp.server/sse
|
npx -p mcp-remote@latest mcp-remote-client https://remote.mcp.server/sse
|
||||||
```
|
```
|
||||||
|
|
||||||
This will run through the entire authorization flow and attempt to list the tools & resources at the remote URL. Pair this with `--clean` or after running `rm -rf ~/.mcp-auth` to see if stale credentials are your problem, otherwise hopefully the issue will be more obvious in these logs than those in your MCP client.
|
This will run through the entire authorization flow and attempt to list the tools & resources at the remote URL. Try this after running `rm -rf ~/.mcp-auth` to see if stale credentials are your problem, otherwise hopefully the issue will be more obvious in these logs than those in your MCP client.
|
||||||
|
|
17
package.json
17
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "mcp-remote",
|
"name": "@kvant/mcp-remote",
|
||||||
"version": "0.0.13",
|
"version": "0.1.5",
|
||||||
"description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth",
|
"description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mcp",
|
"mcp",
|
||||||
|
@ -10,7 +10,7 @@
|
||||||
"oauth"
|
"oauth"
|
||||||
],
|
],
|
||||||
"author": "Glen Maddern <glen@cloudflare.com>",
|
"author": "Glen Maddern <glen@cloudflare.com>",
|
||||||
"repository": "https://github.com/geelen/remote-mcp",
|
"repository": "https://github.com/geelen/mcp-remote",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
@ -23,21 +23,20 @@
|
||||||
"mcp-remote-client": "dist/client.js"
|
"mcp-remote-client": "dist/client.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsup --watch",
|
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
|
"build:watch": "tsup --watch",
|
||||||
"check": "prettier --check . && tsc"
|
"check": "prettier --check . && tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"open": "^10.1.0"
|
"open": "^10.1.0"
|
||||||
},
|
},
|
||||||
|
"packageManager": "pnpm@10.11.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.11.2",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@types/react": "^19.0.12",
|
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"react": "^19.0.0",
|
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
|
@ -53,8 +52,6 @@
|
||||||
"dts": true,
|
"dts": true,
|
||||||
"clean": true,
|
"clean": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"external": [
|
"external": []
|
||||||
"react"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
191
pnpm-lock.yaml
generated
191
pnpm-lock.yaml
generated
|
@ -8,9 +8,6 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@modelcontextprotocol/sdk':
|
|
||||||
specifier: ^1.7.0
|
|
||||||
version: 1.7.0
|
|
||||||
express:
|
express:
|
||||||
specifier: ^4.21.2
|
specifier: ^4.21.2
|
||||||
version: 4.21.2
|
version: 4.21.2
|
||||||
|
@ -18,21 +15,18 @@ importers:
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@modelcontextprotocol/sdk':
|
||||||
|
specifier: ^1.11.2
|
||||||
|
version: 1.11.2
|
||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.0
|
version: 5.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.13.10
|
specifier: ^22.13.10
|
||||||
version: 22.13.10
|
version: 22.13.10
|
||||||
'@types/react':
|
|
||||||
specifier: ^19.0.12
|
|
||||||
version: 19.0.12
|
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.5.3
|
specifier: ^3.5.3
|
||||||
version: 3.5.3
|
version: 3.5.3
|
||||||
react:
|
|
||||||
specifier: ^19.0.0
|
|
||||||
version: 19.0.0
|
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.4.0
|
specifier: ^8.4.0
|
||||||
version: 8.4.0(tsx@4.19.3)(typescript@5.8.2)
|
version: 8.4.0(tsx@4.19.3)(typescript@5.8.2)
|
||||||
|
@ -217,8 +211,8 @@ packages:
|
||||||
'@jridgewell/trace-mapping@0.3.25':
|
'@jridgewell/trace-mapping@0.3.25':
|
||||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.7.0':
|
'@modelcontextprotocol/sdk@1.11.2':
|
||||||
resolution: {integrity: sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==}
|
resolution: {integrity: sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
|
@ -350,9 +344,6 @@ packages:
|
||||||
'@types/range-parser@1.2.7':
|
'@types/range-parser@1.2.7':
|
||||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||||
|
|
||||||
'@types/react@19.0.12':
|
|
||||||
resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==}
|
|
||||||
|
|
||||||
'@types/send@0.17.4':
|
'@types/send@0.17.4':
|
||||||
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
|
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
|
||||||
|
|
||||||
|
@ -396,8 +387,8 @@ packages:
|
||||||
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
|
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
|
||||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||||
|
|
||||||
body-parser@2.1.0:
|
body-parser@2.2.0:
|
||||||
resolution: {integrity: sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==}
|
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
brace-expansion@2.0.1:
|
brace-expansion@2.0.1:
|
||||||
|
@ -479,9 +470,6 @@ packages:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
csstype@3.1.3:
|
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -490,15 +478,6 @@ packages:
|
||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
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:
|
debug@4.4.0:
|
||||||
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
|
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
|
@ -576,12 +555,12 @@ packages:
|
||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
eventsource-parser@3.0.0:
|
eventsource-parser@3.0.1:
|
||||||
resolution: {integrity: sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==}
|
resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
eventsource@3.0.5:
|
eventsource@3.0.6:
|
||||||
resolution: {integrity: sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==}
|
resolution: {integrity: sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
express-rate-limit@7.5.0:
|
express-rate-limit@7.5.0:
|
||||||
|
@ -594,8 +573,8 @@ packages:
|
||||||
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
|
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
|
||||||
engines: {node: '>= 0.10.0'}
|
engines: {node: '>= 0.10.0'}
|
||||||
|
|
||||||
express@5.0.1:
|
express@5.1.0:
|
||||||
resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==}
|
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
fdir@6.4.3:
|
fdir@6.4.3:
|
||||||
|
@ -673,10 +652,6 @@ packages:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
@ -771,8 +746,8 @@ packages:
|
||||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
mime-types@3.0.0:
|
mime-types@3.0.1:
|
||||||
resolution: {integrity: sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==}
|
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
mime@1.6.0:
|
mime@1.6.0:
|
||||||
|
@ -791,9 +766,6 @@ packages:
|
||||||
ms@2.0.0:
|
ms@2.0.0:
|
||||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||||
|
|
||||||
ms@2.1.2:
|
|
||||||
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
@ -860,8 +832,8 @@ packages:
|
||||||
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
pkce-challenge@4.1.0:
|
pkce-challenge@5.0.0:
|
||||||
resolution: {integrity: sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==}
|
resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==}
|
||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
|
|
||||||
postcss-load-config@6.0.1:
|
postcss-load-config@6.0.1:
|
||||||
|
@ -915,10 +887,6 @@ packages:
|
||||||
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
|
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
react@19.0.0:
|
|
||||||
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
readdirp@4.1.2:
|
readdirp@4.1.2:
|
||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
|
@ -935,8 +903,8 @@ packages:
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
router@2.1.0:
|
router@2.2.0:
|
||||||
resolution: {integrity: sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==}
|
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
run-applescript@7.0.0:
|
run-applescript@7.0.0:
|
||||||
|
@ -953,16 +921,16 @@ packages:
|
||||||
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
|
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
send@1.1.0:
|
send@1.2.0:
|
||||||
resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==}
|
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
serve-static@1.16.2:
|
serve-static@1.16.2:
|
||||||
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
|
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
serve-static@2.1.0:
|
serve-static@2.2.0:
|
||||||
resolution: {integrity: sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==}
|
resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
|
@ -1081,8 +1049,8 @@ packages:
|
||||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
type-is@2.0.0:
|
type-is@2.0.1:
|
||||||
resolution: {integrity: sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==}
|
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
typescript@5.8.2:
|
typescript@5.8.2:
|
||||||
|
@ -1132,8 +1100,8 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.24.1
|
zod: ^3.24.1
|
||||||
|
|
||||||
zod@3.24.2:
|
zod@3.24.3:
|
||||||
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
|
resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
@ -1238,17 +1206,18 @@ snapshots:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.7.0':
|
'@modelcontextprotocol/sdk@1.11.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
cors: 2.8.5
|
cors: 2.8.5
|
||||||
eventsource: 3.0.5
|
cross-spawn: 7.0.6
|
||||||
express: 5.0.1
|
eventsource: 3.0.6
|
||||||
express-rate-limit: 7.5.0(express@5.0.1)
|
express: 5.1.0
|
||||||
pkce-challenge: 4.1.0
|
express-rate-limit: 7.5.0(express@5.1.0)
|
||||||
|
pkce-challenge: 5.0.0
|
||||||
raw-body: 3.0.0
|
raw-body: 3.0.0
|
||||||
zod: 3.24.2
|
zod: 3.24.3
|
||||||
zod-to-json-schema: 3.24.5(zod@3.24.2)
|
zod-to-json-schema: 3.24.5(zod@3.24.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
@ -1349,10 +1318,6 @@ snapshots:
|
||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
|
|
||||||
'@types/react@19.0.12':
|
|
||||||
dependencies:
|
|
||||||
csstype: 3.1.3
|
|
||||||
|
|
||||||
'@types/send@0.17.4':
|
'@types/send@0.17.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mime': 1.3.5
|
'@types/mime': 1.3.5
|
||||||
|
@ -1371,7 +1336,7 @@ snapshots:
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 3.0.0
|
mime-types: 3.0.1
|
||||||
negotiator: 1.0.0
|
negotiator: 1.0.0
|
||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
|
@ -1407,17 +1372,17 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
body-parser@2.1.0:
|
body-parser@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
debug: 4.4.0
|
debug: 4.4.0
|
||||||
http-errors: 2.0.0
|
http-errors: 2.0.0
|
||||||
iconv-lite: 0.5.2
|
iconv-lite: 0.6.3
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
qs: 6.14.0
|
qs: 6.14.0
|
||||||
raw-body: 3.0.0
|
raw-body: 3.0.0
|
||||||
type-is: 2.0.0
|
type-is: 2.0.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
@ -1489,16 +1454,10 @@ snapshots:
|
||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.0.0
|
ms: 2.0.0
|
||||||
|
|
||||||
debug@4.3.6:
|
|
||||||
dependencies:
|
|
||||||
ms: 2.1.2
|
|
||||||
|
|
||||||
debug@4.4.0:
|
debug@4.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
@ -1574,15 +1533,15 @@ snapshots:
|
||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
||||||
eventsource-parser@3.0.0: {}
|
eventsource-parser@3.0.1: {}
|
||||||
|
|
||||||
eventsource@3.0.5:
|
eventsource@3.0.6:
|
||||||
dependencies:
|
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:
|
dependencies:
|
||||||
express: 5.0.1
|
express: 5.1.0
|
||||||
|
|
||||||
express@4.21.2:
|
express@4.21.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -1620,16 +1579,15 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
express@5.0.1:
|
express@5.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
accepts: 2.0.0
|
accepts: 2.0.0
|
||||||
body-parser: 2.1.0
|
body-parser: 2.2.0
|
||||||
content-disposition: 1.0.0
|
content-disposition: 1.0.0
|
||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
cookie: 0.7.1
|
cookie: 0.7.1
|
||||||
cookie-signature: 1.2.2
|
cookie-signature: 1.2.2
|
||||||
debug: 4.3.6
|
debug: 4.4.0
|
||||||
depd: 2.0.0
|
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
etag: 1.8.1
|
etag: 1.8.1
|
||||||
|
@ -1637,22 +1595,18 @@ snapshots:
|
||||||
fresh: 2.0.0
|
fresh: 2.0.0
|
||||||
http-errors: 2.0.0
|
http-errors: 2.0.0
|
||||||
merge-descriptors: 2.0.0
|
merge-descriptors: 2.0.0
|
||||||
methods: 1.1.2
|
mime-types: 3.0.1
|
||||||
mime-types: 3.0.0
|
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
once: 1.4.0
|
once: 1.4.0
|
||||||
parseurl: 1.3.3
|
parseurl: 1.3.3
|
||||||
proxy-addr: 2.0.7
|
proxy-addr: 2.0.7
|
||||||
qs: 6.13.0
|
qs: 6.14.0
|
||||||
range-parser: 1.2.1
|
range-parser: 1.2.1
|
||||||
router: 2.1.0
|
router: 2.2.0
|
||||||
safe-buffer: 5.2.1
|
send: 1.2.0
|
||||||
send: 1.1.0
|
serve-static: 2.2.0
|
||||||
serve-static: 2.1.0
|
|
||||||
setprototypeof: 1.2.0
|
|
||||||
statuses: 2.0.1
|
statuses: 2.0.1
|
||||||
type-is: 2.0.0
|
type-is: 2.0.1
|
||||||
utils-merge: 1.0.1
|
|
||||||
vary: 1.1.2
|
vary: 1.1.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
@ -1751,10 +1705,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
iconv-lite@0.5.2:
|
|
||||||
dependencies:
|
|
||||||
safer-buffer: 2.1.2
|
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
@ -1817,7 +1767,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.52.0
|
mime-db: 1.52.0
|
||||||
|
|
||||||
mime-types@3.0.0:
|
mime-types@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.54.0
|
mime-db: 1.54.0
|
||||||
|
|
||||||
|
@ -1831,8 +1781,6 @@ snapshots:
|
||||||
|
|
||||||
ms@2.0.0: {}
|
ms@2.0.0: {}
|
||||||
|
|
||||||
ms@2.1.2: {}
|
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
|
@ -1885,7 +1833,7 @@ snapshots:
|
||||||
|
|
||||||
pirates@4.0.6: {}
|
pirates@4.0.6: {}
|
||||||
|
|
||||||
pkce-challenge@4.1.0: {}
|
pkce-challenge@5.0.0: {}
|
||||||
|
|
||||||
postcss-load-config@6.0.1(tsx@4.19.3):
|
postcss-load-config@6.0.1(tsx@4.19.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -1926,8 +1874,6 @@ snapshots:
|
||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
unpipe: 1.0.0
|
unpipe: 1.0.0
|
||||||
|
|
||||||
react@19.0.0: {}
|
|
||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
resolve-from@5.0.0: {}
|
resolve-from@5.0.0: {}
|
||||||
|
@ -1959,11 +1905,15 @@ snapshots:
|
||||||
'@rollup/rollup-win32-x64-msvc': 4.35.0
|
'@rollup/rollup-win32-x64-msvc': 4.35.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
router@2.1.0:
|
router@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
debug: 4.4.0
|
||||||
|
depd: 2.0.0
|
||||||
is-promise: 4.0.0
|
is-promise: 4.0.0
|
||||||
parseurl: 1.3.3
|
parseurl: 1.3.3
|
||||||
path-to-regexp: 8.2.0
|
path-to-regexp: 8.2.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
run-applescript@7.0.0: {}
|
run-applescript@7.0.0: {}
|
||||||
|
|
||||||
|
@ -1989,16 +1939,15 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
send@1.1.0:
|
send@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.0
|
debug: 4.4.0
|
||||||
destroy: 1.2.0
|
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
etag: 1.8.1
|
etag: 1.8.1
|
||||||
fresh: 0.5.2
|
fresh: 2.0.0
|
||||||
http-errors: 2.0.0
|
http-errors: 2.0.0
|
||||||
mime-types: 2.1.35
|
mime-types: 3.0.1
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
range-parser: 1.2.1
|
range-parser: 1.2.1
|
||||||
|
@ -2015,12 +1964,12 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
serve-static@2.1.0:
|
serve-static@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
parseurl: 1.3.3
|
parseurl: 1.3.3
|
||||||
send: 1.1.0
|
send: 1.2.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
@ -2161,11 +2110,11 @@ snapshots:
|
||||||
media-typer: 0.3.0
|
media-typer: 0.3.0
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|
||||||
type-is@2.0.0:
|
type-is@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
media-typer: 1.1.0
|
media-typer: 1.1.0
|
||||||
mime-types: 3.0.0
|
mime-types: 3.0.1
|
||||||
|
|
||||||
typescript@5.8.2: {}
|
typescript@5.8.2: {}
|
||||||
|
|
||||||
|
@ -2203,8 +2152,8 @@ snapshots:
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
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:
|
dependencies:
|
||||||
zod: 3.24.2
|
zod: 3.24.3
|
||||||
|
|
||||||
zod@3.24.2: {}
|
zod@3.24.3: {}
|
||||||
|
|
164
src/client.ts
164
src/client.ts
|
@ -4,35 +4,49 @@
|
||||||
* MCP Client with OAuth support
|
* MCP Client with OAuth support
|
||||||
* A command-line client that connects to an MCP server using SSE with OAuth authentication.
|
* A command-line client that connects to an MCP server using SSE with OAuth authentication.
|
||||||
*
|
*
|
||||||
* Run with: npx tsx client.ts [--clean] https://example.remote/server [callback-port]
|
* Run with: npx tsx client.ts https://example.remote/server [callback-port]
|
||||||
*
|
|
||||||
* Options:
|
|
||||||
* --clean: Deletes stored configuration before reading, ensuring a fresh session
|
|
||||||
*
|
*
|
||||||
* If callback-port is not specified, an available port will be automatically selected.
|
* If callback-port is not specified, an available port will be automatically selected.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
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 { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||||
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
|
||||||
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
|
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
|
||||||
import { parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers, MCP_REMOTE_VERSION } from './lib/utils'
|
import {
|
||||||
|
parseCommandLineArgs,
|
||||||
|
setupSignalHandlers,
|
||||||
|
log,
|
||||||
|
MCP_REMOTE_VERSION,
|
||||||
|
getServerUrlHash,
|
||||||
|
connectToRemoteServer,
|
||||||
|
TransportStrategy,
|
||||||
|
} from './lib/utils'
|
||||||
|
import { createLazyAuthCoordinator } from './lib/coordination'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main function to run the client
|
* Main function to run the client
|
||||||
*/
|
*/
|
||||||
async function runClient(serverUrl: string, callbackPort: number, clean: boolean = false) {
|
async function runClient(
|
||||||
|
serverUrl: string,
|
||||||
|
callbackPort: number,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
transportStrategy: TransportStrategy = 'http-first',
|
||||||
|
) {
|
||||||
// Set up event emitter for auth flow
|
// Set up event emitter for auth flow
|
||||||
const events = new EventEmitter()
|
const events = new EventEmitter()
|
||||||
|
|
||||||
|
// Get the server URL hash for lockfile operations
|
||||||
|
const serverUrlHash = getServerUrlHash(serverUrl)
|
||||||
|
|
||||||
|
// Create a lazy auth coordinator
|
||||||
|
const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events)
|
||||||
|
|
||||||
// Create the OAuth client provider
|
// Create the OAuth client provider
|
||||||
const authProvider = new NodeOAuthClientProvider({
|
const authProvider = new NodeOAuthClientProvider({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
callbackPort,
|
callbackPort,
|
||||||
clientName: 'MCP CLI Client',
|
clientName: 'MCP CLI Client',
|
||||||
clean,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create the client
|
// Create the client
|
||||||
|
@ -46,114 +60,100 @@ async function runClient(serverUrl: string, callbackPort: number, clean: boolean
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create the transport factory
|
// Keep track of the server instance for cleanup
|
||||||
const url = new URL(serverUrl)
|
let server: any = null
|
||||||
function initTransport() {
|
|
||||||
const transport = new SSEClientTransport(url, { authProvider })
|
// 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
|
// Set up message and error handlers
|
||||||
transport.onmessage = (message) => {
|
transport.onmessage = (message) => {
|
||||||
console.log('Received message:', JSON.stringify(message, null, 2))
|
log('Received message:', JSON.stringify(message, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.onerror = (error) => {
|
transport.onerror = (error) => {
|
||||||
console.error('Transport error:', error)
|
log('Transport error:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
console.log('Connection closed.')
|
log('Connection closed.')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
return transport
|
|
||||||
}
|
|
||||||
|
|
||||||
const transport = initTransport()
|
|
||||||
|
|
||||||
// Set up an HTTP server to handle OAuth callback
|
|
||||||
const { server, waitForAuthCode } = setupOAuthCallbackServer({
|
|
||||||
port: callbackPort,
|
|
||||||
path: '/oauth/callback',
|
|
||||||
events,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set up cleanup handler
|
// Set up cleanup handler
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
console.log('\nClosing connection...')
|
log('\nClosing connection...')
|
||||||
await client.close()
|
await client.close()
|
||||||
|
// If auth was initialized and server was created, close it
|
||||||
|
if (server) {
|
||||||
server.close()
|
server.close()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setupSignalHandlers(cleanup)
|
setupSignalHandlers(cleanup)
|
||||||
|
|
||||||
// Try to connect
|
log('Connected successfully!')
|
||||||
try {
|
|
||||||
console.log('Connecting to server...')
|
|
||||||
await client.connect(transport)
|
|
||||||
console.log('Connected successfully!')
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
|
||||||
console.log('Authentication required. Waiting for authorization...')
|
|
||||||
|
|
||||||
// Wait for the authorization code from the callback
|
|
||||||
const code = await waitForAuthCode()
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Completing authorization...')
|
|
||||||
await transport.finishAuth(code)
|
|
||||||
|
|
||||||
// Reconnect after authorization with a new transport
|
|
||||||
console.log('Connecting after authorization...')
|
|
||||||
await client.connect(initTransport())
|
|
||||||
|
|
||||||
console.log('Connected successfully!')
|
|
||||||
|
|
||||||
// Request tools list after auth
|
|
||||||
console.log('Requesting tools list...')
|
|
||||||
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
|
||||||
console.log('Tools:', JSON.stringify(tools, null, 2))
|
|
||||||
|
|
||||||
// Request resources list after auth
|
|
||||||
console.log('Requesting resource list...')
|
|
||||||
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
|
||||||
console.log('Resources:', JSON.stringify(resources, null, 2))
|
|
||||||
|
|
||||||
console.log('Listening for messages. Press Ctrl+C to exit.')
|
|
||||||
} catch (authError) {
|
|
||||||
console.error('Authorization error:', authError)
|
|
||||||
server.close()
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Connection error:', error)
|
|
||||||
server.close()
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request tools list
|
// Request tools list
|
||||||
console.log('Requesting tools list...')
|
log('Requesting tools list...')
|
||||||
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||||
console.log('Tools:', JSON.stringify(tools, null, 2))
|
log('Tools:', JSON.stringify(tools, null, 2))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Error requesting tools list:', e)
|
log('Error requesting tools list:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request resources list
|
// Request resources list
|
||||||
console.log('Requesting resource list...')
|
log('Requesting resource list...')
|
||||||
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
||||||
console.log('Resources:', JSON.stringify(resources, null, 2))
|
log('Resources:', JSON.stringify(resources, null, 2))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Error requesting resources list:', e)
|
log('Error requesting resources list:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Listening for messages. Press Ctrl+C to exit.')
|
// 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
|
// Parse command-line arguments and run the client
|
||||||
parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [--clean] <https://server-url> [callback-port]')
|
parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts <https://server-url> [callback-port]')
|
||||||
.then(({ serverUrl, callbackPort, clean }) => {
|
.then(({ serverUrl, callbackPort, headers, transportStrategy }) => {
|
||||||
return runClient(serverUrl, callbackPort, clean)
|
return runClient(serverUrl, callbackPort, headers, transportStrategy)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Fatal error:', error)
|
console.error('Fatal error:', error)
|
||||||
|
|
222
src/lib/coordination.ts
Normal file
222
src/lib/coordination.ts
Normal file
|
@ -0,0 +1,222 @@
|
||||||
|
import { checkLockfile, createLockfile, deleteLockfile, getConfigFilePath, LockfileData } from './mcp-auth-config'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { Server } from 'http'
|
||||||
|
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
|
||||||
|
* @returns True if the process is running, false otherwise
|
||||||
|
*/
|
||||||
|
export async function isPidRunning(pid: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0) // Doesn't kill the process, just checks if it exists
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a lockfile is valid (process running and endpoint accessible)
|
||||||
|
* @param lockData The lockfile data
|
||||||
|
* @returns True if the lockfile is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export async function isLockValid(lockData: LockfileData): Promise<boolean> {
|
||||||
|
// Check if the lockfile is too old (over 30 minutes)
|
||||||
|
const MAX_LOCK_AGE = 30 * 60 * 1000 // 30 minutes
|
||||||
|
if (Date.now() - lockData.timestamp > MAX_LOCK_AGE) {
|
||||||
|
log('Lockfile is too old')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the process is still running
|
||||||
|
if (!(await isPidRunning(lockData.pid))) {
|
||||||
|
log('Process from lockfile is not running')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the endpoint is accessible
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 1000)
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeout)
|
||||||
|
return response.status === 200 || response.status === 202
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error connecting to auth server: ${(error as Error).message}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for authentication from another server instance
|
||||||
|
* @param port The port to connect to
|
||||||
|
* @returns True if authentication completed successfully, false otherwise
|
||||||
|
*/
|
||||||
|
export async function waitForAuthentication(port: number): Promise<boolean> {
|
||||||
|
log(`Waiting for authentication from the server on port ${port}...`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const url = `http://127.0.0.1:${port}/wait-for-auth`
|
||||||
|
log(`Querying: ${url}`)
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
// Auth completed, but we don't return the code anymore
|
||||||
|
log(`Authentication completed by other instance`)
|
||||||
|
return true
|
||||||
|
} else if (response.status === 202) {
|
||||||
|
// Continue polling
|
||||||
|
log(`Authentication still in progress`)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
} else {
|
||||||
|
log(`Unexpected response status: ${response.status}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error waiting for authentication: ${(error as Error).message}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* @param callbackPort The port to use for the callback server
|
||||||
|
* @param events The event emitter to use for signaling
|
||||||
|
* @returns An object with the server, waitForAuthCode function, and a flag indicating if browser auth can be skipped
|
||||||
|
*/
|
||||||
|
export async function coordinateAuth(
|
||||||
|
serverUrlHash: string,
|
||||||
|
callbackPort: number,
|
||||||
|
events: EventEmitter,
|
||||||
|
): Promise<{ server: Server; waitForAuthCode: () => Promise<string>; skipBrowserAuth: boolean }> {
|
||||||
|
// Check for a lockfile (disabled on Windows for the time being)
|
||||||
|
const lockData = process.platform === 'win32' ? null : await checkLockfile(serverUrlHash)
|
||||||
|
|
||||||
|
// If there's a valid lockfile, try to use the existing auth process
|
||||||
|
if (lockData && (await isLockValid(lockData))) {
|
||||||
|
log(`Another instance is handling authentication on port ${lockData.port}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to wait for the authentication to complete
|
||||||
|
const authCompleted = await waitForAuthentication(lockData.port)
|
||||||
|
if (authCompleted) {
|
||||||
|
log('Authentication completed by another instance')
|
||||||
|
|
||||||
|
// Setup a dummy server - the client will use tokens directly from disk
|
||||||
|
const dummyServer = express().listen(0) // Listen on any available port
|
||||||
|
|
||||||
|
// This shouldn't actually be called in normal operation, but provide it for API compatibility
|
||||||
|
const dummyWaitForAuthCode = () => {
|
||||||
|
log('WARNING: waitForAuthCode called in secondary instance - this is unexpected')
|
||||||
|
// Return a promise that never resolves - the client should use the tokens from disk instead
|
||||||
|
return new Promise<string>(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: dummyServer,
|
||||||
|
waitForAuthCode: dummyWaitForAuthCode,
|
||||||
|
skipBrowserAuth: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('Taking over authentication process...')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error waiting for authentication: ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, the other process didn't complete auth successfully
|
||||||
|
await deleteLockfile(serverUrlHash)
|
||||||
|
} else if (lockData) {
|
||||||
|
// Invalid lockfile, delete its
|
||||||
|
log('Found invalid lockfile, deleting it')
|
||||||
|
await deleteLockfile(serverUrlHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create our own lockfile
|
||||||
|
const { server, waitForAuthCode, authCompletedPromise } = setupOAuthCallbackServerWithLongPoll({
|
||||||
|
port: callbackPort,
|
||||||
|
path: '/oauth/callback',
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the actual port the server is running on
|
||||||
|
const address = server.address() as AddressInfo
|
||||||
|
const actualPort = address.port
|
||||||
|
|
||||||
|
log(`Creating lockfile for server ${serverUrlHash} with process ${process.pid} on port ${actualPort}`)
|
||||||
|
await createLockfile(serverUrlHash, process.pid, actualPort)
|
||||||
|
|
||||||
|
// Make sure lockfile is deleted on process exit
|
||||||
|
const cleanupHandler = async () => {
|
||||||
|
try {
|
||||||
|
log(`Cleaning up lockfile for server ${serverUrlHash}`)
|
||||||
|
await deleteLockfile(serverUrlHash)
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error cleaning up lockfile: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once('exit', () => {
|
||||||
|
try {
|
||||||
|
// Synchronous version for 'exit' event since we can't use async here
|
||||||
|
const configPath = getConfigFilePath(serverUrlHash, 'lock.json')
|
||||||
|
require('fs').unlinkSync(configPath)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also handle SIGINT separately
|
||||||
|
process.once('SIGINT', async () => {
|
||||||
|
await cleanupHandler()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
waitForAuthCode,
|
||||||
|
skipBrowserAuth: false,
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,3 @@
|
||||||
import crypto from 'crypto'
|
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
|
@ -25,19 +24,57 @@ import { log, MCP_REMOTE_VERSION } from './utils'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Known configuration file names that might need to be cleaned
|
* Lockfile data structure
|
||||||
*/
|
*/
|
||||||
export const knownConfigFiles = ['client_info.json', 'tokens.json', 'code_verifier.txt']
|
export interface LockfileData {
|
||||||
|
pid: number
|
||||||
|
port: number
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes all known configuration files for a specific server
|
* Creates a lockfile for the given server
|
||||||
|
* @param serverUrlHash The hash of the server URL
|
||||||
|
* @param pid The process ID
|
||||||
|
* @param port The port the server is running on
|
||||||
|
*/
|
||||||
|
export async function createLockfile(serverUrlHash: string, pid: number, port: number): Promise<void> {
|
||||||
|
const lockData: LockfileData = {
|
||||||
|
pid,
|
||||||
|
port,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
await writeJsonFile(serverUrlHash, 'lock.json', lockData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a lockfile exists for the given server
|
||||||
|
* @param serverUrlHash The hash of the server URL
|
||||||
|
* @returns The lockfile data or null if it doesn't exist
|
||||||
|
*/
|
||||||
|
export async function checkLockfile(serverUrlHash: string): Promise<LockfileData | null> {
|
||||||
|
try {
|
||||||
|
const lockfile = await readJsonFile<LockfileData>(serverUrlHash, 'lock.json', {
|
||||||
|
async parseAsync(data: any) {
|
||||||
|
if (typeof data !== 'object' || data === null) return null
|
||||||
|
if (typeof data.pid !== 'number' || typeof data.port !== 'number' || typeof data.timestamp !== 'number') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return data as LockfileData
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return lockfile || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the lockfile for the given server
|
||||||
* @param serverUrlHash The hash of the server URL
|
* @param serverUrlHash The hash of the server URL
|
||||||
*/
|
*/
|
||||||
export async function cleanServerConfig(serverUrlHash: string): Promise<void> {
|
export async function deleteLockfile(serverUrlHash: string): Promise<void> {
|
||||||
log(`Cleaning configuration files for server: ${serverUrlHash}`)
|
await deleteConfigFile(serverUrlHash, 'lock.json')
|
||||||
for (const filename of knownConfigFiles) {
|
|
||||||
await deleteConfigFile(serverUrlHash, filename)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -63,15 +100,6 @@ export async function ensureConfigDir(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a hash for the server URL to use in filenames
|
|
||||||
* @param serverUrl The server URL to hash
|
|
||||||
* @returns The hashed server URL
|
|
||||||
*/
|
|
||||||
export function getServerUrlHash(serverUrl: string): string {
|
|
||||||
return crypto.createHash('md5').update(serverUrl).digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the file path for a config file
|
* Gets the file path for a config file
|
||||||
* @param serverUrlHash The hash of the server URL
|
* @param serverUrlHash The hash of the server URL
|
||||||
|
@ -105,31 +133,23 @@ export async function deleteConfigFile(serverUrlHash: string, filename: string):
|
||||||
* @param serverUrlHash The hash of the server URL
|
* @param serverUrlHash The hash of the server URL
|
||||||
* @param filename The name of the file to read
|
* @param filename The name of the file to read
|
||||||
* @param schema The schema to validate against
|
* @param schema The schema to validate against
|
||||||
* @param clean Whether to clean (delete) before reading
|
|
||||||
* @returns The parsed file content or undefined if the file doesn't exist
|
* @returns The parsed file content or undefined if the file doesn't exist
|
||||||
*/
|
*/
|
||||||
export async function readJsonFile<T>(
|
export async function readJsonFile<T>(serverUrlHash: string, filename: string, schema: any): Promise<T | undefined> {
|
||||||
serverUrlHash: string,
|
|
||||||
filename: string,
|
|
||||||
schema: any,
|
|
||||||
clean: boolean = false,
|
|
||||||
): Promise<T | undefined> {
|
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir()
|
||||||
|
|
||||||
// If clean flag is set, delete the file before trying to read it
|
|
||||||
if (clean) {
|
|
||||||
await deleteConfigFile(serverUrlHash, filename)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
const filePath = getConfigFilePath(serverUrlHash, filename)
|
||||||
const content = await fs.readFile(filePath, 'utf-8')
|
const content = await fs.readFile(filePath, 'utf-8')
|
||||||
return await schema.parseAsync(JSON.parse(content))
|
const result = await schema.parseAsync(JSON.parse(content))
|
||||||
|
// console.log({ filename: result })
|
||||||
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
// console.log(`File ${filename} does not exist`)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
log(`Error reading ${filename}:`, error)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -156,24 +176,11 @@ export async function writeJsonFile(serverUrlHash: string, filename: string, dat
|
||||||
* @param serverUrlHash The hash of the server URL
|
* @param serverUrlHash The hash of the server URL
|
||||||
* @param filename The name of the file to read
|
* @param filename The name of the file to read
|
||||||
* @param errorMessage Optional custom error message
|
* @param errorMessage Optional custom error message
|
||||||
* @param clean Whether to clean (delete) before reading
|
|
||||||
* @returns The file content as a string
|
* @returns The file content as a string
|
||||||
*/
|
*/
|
||||||
export async function readTextFile(
|
export async function readTextFile(serverUrlHash: string, filename: string, errorMessage?: string): Promise<string> {
|
||||||
serverUrlHash: string,
|
|
||||||
filename: string,
|
|
||||||
errorMessage?: string,
|
|
||||||
clean: boolean = false,
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir()
|
||||||
|
|
||||||
// If clean flag is set, delete the file before trying to read it
|
|
||||||
if (clean) {
|
|
||||||
await deleteConfigFile(serverUrlHash, filename)
|
|
||||||
throw new Error('File deleted due to clean flag')
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = getConfigFilePath(serverUrlHash, filename)
|
const filePath = getConfigFilePath(serverUrlHash, filename)
|
||||||
return await fs.readFile(filePath, 'utf-8')
|
return await fs.readFile(filePath, 'utf-8')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
import open from 'open'
|
import open from 'open'
|
||||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||||
import {
|
import {
|
||||||
OAuthClientInformation,
|
|
||||||
OAuthClientInformationFull,
|
OAuthClientInformationFull,
|
||||||
OAuthClientInformationSchema,
|
OAuthClientInformationFullSchema,
|
||||||
OAuthTokens,
|
OAuthTokens,
|
||||||
OAuthTokensSchema,
|
OAuthTokensSchema,
|
||||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||||
import type { OAuthProviderOptions } from './types'
|
import type { OAuthProviderOptions } from './types'
|
||||||
import { getServerUrlHash, readJsonFile, writeJsonFile, readTextFile, writeTextFile, cleanServerConfig } from './mcp-auth-config'
|
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile } from './mcp-auth-config'
|
||||||
import { log } from './utils'
|
import { getServerUrlHash, log, MCP_REMOTE_VERSION } from './utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implements the OAuthClientProvider interface for Node.js environments.
|
* Implements the OAuthClientProvider interface for Node.js environments.
|
||||||
|
@ -20,6 +19,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
private callbackPath: string
|
private callbackPath: string
|
||||||
private clientName: string
|
private clientName: string
|
||||||
private clientUri: string
|
private clientUri: string
|
||||||
|
private softwareId: string
|
||||||
|
private softwareVersion: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new NodeOAuthClientProvider
|
* Creates a new NodeOAuthClientProvider
|
||||||
|
@ -30,17 +31,12 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
this.callbackPath = options.callbackPath || '/oauth/callback'
|
this.callbackPath = options.callbackPath || '/oauth/callback'
|
||||||
this.clientName = options.clientName || 'MCP CLI Client'
|
this.clientName = options.clientName || 'MCP CLI Client'
|
||||||
this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli'
|
this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli'
|
||||||
|
this.softwareId = options.softwareId || '2e6dc280-f3c3-4e01-99a7-8181dbd1d23d'
|
||||||
// If clean flag is set, proactively clean all config files for this server
|
this.softwareVersion = options.softwareVersion || MCP_REMOTE_VERSION
|
||||||
if (options.clean) {
|
|
||||||
cleanServerConfig(this.serverUrlHash).catch((err) => {
|
|
||||||
log('Error cleaning server config:', err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get redirectUrl(): string {
|
get redirectUrl(): string {
|
||||||
return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}`
|
return `http://localhost:${this.options.callbackPort}${this.callbackPath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
get clientMetadata() {
|
get clientMetadata() {
|
||||||
|
@ -51,6 +47,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
response_types: ['code'],
|
response_types: ['code'],
|
||||||
client_name: this.clientName,
|
client_name: this.clientName,
|
||||||
client_uri: this.clientUri,
|
client_uri: this.clientUri,
|
||||||
|
software_id: this.softwareId,
|
||||||
|
software_version: this.softwareVersion,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,8 +56,9 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
* Gets the client information if it exists
|
* Gets the client information if it exists
|
||||||
* @returns The client information or undefined
|
* @returns The client information or undefined
|
||||||
*/
|
*/
|
||||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
async clientInformation(): Promise<OAuthClientInformationFull | undefined> {
|
||||||
return readJsonFile<OAuthClientInformation>(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema, this.options.clean)
|
// log('Reading client info')
|
||||||
|
return readJsonFile<OAuthClientInformationFull>(this.serverUrlHash, 'client_info.json', OAuthClientInformationFullSchema)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -67,6 +66,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
* @param clientInformation The client information to save
|
* @param clientInformation The client information to save
|
||||||
*/
|
*/
|
||||||
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
|
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
|
||||||
|
// log('Saving client info')
|
||||||
await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation)
|
await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,7 +75,9 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
* @returns The OAuth tokens or undefined
|
* @returns The OAuth tokens or undefined
|
||||||
*/
|
*/
|
||||||
async tokens(): Promise<OAuthTokens | undefined> {
|
async tokens(): Promise<OAuthTokens | undefined> {
|
||||||
return readJsonFile<OAuthTokens>(this.serverUrlHash, 'tokens.json', OAuthTokensSchema, this.options.clean)
|
// log('Reading tokens')
|
||||||
|
// console.log(new Error().stack)
|
||||||
|
return readJsonFile<OAuthTokens>(this.serverUrlHash, 'tokens.json', OAuthTokensSchema)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -83,6 +85,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
* @param tokens The tokens to save
|
* @param tokens The tokens to save
|
||||||
*/
|
*/
|
||||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
|
// log('Saving tokens')
|
||||||
await writeJsonFile(this.serverUrlHash, 'tokens.json', tokens)
|
await writeJsonFile(this.serverUrlHash, 'tokens.json', tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,6 +108,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
* @param codeVerifier The code verifier to save
|
* @param codeVerifier The code verifier to save
|
||||||
*/
|
*/
|
||||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||||
|
// log('Saving code verifier')
|
||||||
await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier)
|
await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,6 +117,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
* @returns The code verifier
|
* @returns The code verifier
|
||||||
*/
|
*/
|
||||||
async codeVerifier(): Promise<string> {
|
async codeVerifier(): Promise<string> {
|
||||||
return await readTextFile(this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session', this.options.clean)
|
// log('Reading code verifier')
|
||||||
|
return await readTextFile(this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,10 @@ export interface OAuthProviderOptions {
|
||||||
clientName?: string
|
clientName?: string
|
||||||
/** Client URI to use for OAuth registration */
|
/** Client URI to use for OAuth registration */
|
||||||
clientUri?: string
|
clientUri?: string
|
||||||
/** Whether to clean stored configuration before reading */
|
/** Software ID to use for OAuth registration */
|
||||||
clean?: boolean
|
softwareId?: string
|
||||||
|
/** Software version to use for OAuth registration */
|
||||||
|
softwareVersion?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
371
src/lib/utils.ts
371
src/lib/utils.ts
|
@ -1,9 +1,25 @@
|
||||||
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
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 { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||||
|
import { OAuthClientInformationFull, OAuthClientInformationFullSchema } from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||||
import { OAuthCallbackServerOptions } from './types'
|
import { OAuthCallbackServerOptions } from './types'
|
||||||
|
import { getConfigFilePath, readJsonFile } from './mcp-auth-config'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import net from 'net'
|
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
|
||||||
|
|
||||||
const pid = process.pid
|
const pid = process.pid
|
||||||
export function log(str: string, ...rest: unknown[]) {
|
export function log(str: string, ...rest: unknown[]) {
|
||||||
|
@ -19,14 +35,21 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
|
||||||
let transportToClientClosed = false
|
let transportToClientClosed = false
|
||||||
let transportToServerClosed = false
|
let transportToServerClosed = false
|
||||||
|
|
||||||
transportToClient.onmessage = (message) => {
|
transportToClient.onmessage = (_message) => {
|
||||||
// @ts-expect-error TODO
|
// TODO: fix types
|
||||||
|
const message = _message as any
|
||||||
log('[Local→Remote]', message.method || message.id)
|
log('[Local→Remote]', message.method || message.id)
|
||||||
|
if (message.method === 'initialize') {
|
||||||
|
const { clientInfo } = message.params
|
||||||
|
if (clientInfo) clientInfo.name = `${clientInfo.name} (via mcp-remote ${MCP_REMOTE_VERSION})`
|
||||||
|
log(JSON.stringify(message, null, 2))
|
||||||
|
}
|
||||||
transportToServer.send(message).catch(onServerError)
|
transportToServer.send(message).catch(onServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
transportToServer.onmessage = (message) => {
|
transportToServer.onmessage = (_message) => {
|
||||||
// @ts-expect-error TODO: fix this type
|
// TODO: fix types
|
||||||
|
const message = _message as any
|
||||||
log('[Remote→Local]', message.method || message.id)
|
log('[Remote→Local]', message.method || message.id)
|
||||||
transportToClient.send(message).catch(onClientError)
|
transportToClient.send(message).catch(onClientError)
|
||||||
}
|
}
|
||||||
|
@ -61,28 +84,134 @@ 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 serverUrl The URL of the remote server
|
||||||
* @param authProvider The OAuth client provider
|
* @param authProvider The OAuth client provider
|
||||||
* @param waitForAuthCode Function to wait for the auth code
|
* @param headers Additional headers to send with the request
|
||||||
* @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(
|
export async function connectToRemoteServer(
|
||||||
|
client: Client | null,
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
authProvider: OAuthClientProvider,
|
authProvider: OAuthClientProvider,
|
||||||
waitForAuthCode: () => Promise<string>,
|
headers: Record<string, string>,
|
||||||
): Promise<SSEClientTransport> {
|
authInitializer: AuthInitializer,
|
||||||
|
transportStrategy: TransportStrategy = 'http-first',
|
||||||
|
recursionReasons: Set<string> = new Set(),
|
||||||
|
): Promise<Transport> {
|
||||||
log(`[${pid}] Connecting to remote server: ${serverUrl}`)
|
log(`[${pid}] Connecting to remote server: ${serverUrl}`)
|
||||||
const url = new URL(serverUrl)
|
const url = new URL(serverUrl)
|
||||||
const transport = new SSEClientTransport(url, { authProvider })
|
|
||||||
|
// Create transport with eventSourceInit to pass Authorization header if present
|
||||||
|
const eventSourceInit = {
|
||||||
|
fetch: (url: string | URL, init?: RequestInit) => {
|
||||||
|
return Promise.resolve(authProvider?.tokens?.()).then((tokens) =>
|
||||||
|
fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init?.headers as Record<string, string> | undefined),
|
||||||
|
...headers,
|
||||||
|
...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}),
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
} as Record<string, string>,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
|
if (client) {
|
||||||
|
await client.connect(transport)
|
||||||
|
} else {
|
||||||
await transport.start()
|
await transport.start()
|
||||||
log('Connected to remote server')
|
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
|
return transport
|
||||||
} catch (error) {
|
} 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 {
|
||||||
log('Authentication required. Waiting for authorization...')
|
log('Authentication required. Waiting for authorization...')
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for the authorization code from the callback
|
// Wait for the authorization code from the callback
|
||||||
const code = await waitForAuthCode()
|
const code = await waitForAuthCode()
|
||||||
|
@ -91,11 +220,18 @@ export async function connectToRemoteServer(
|
||||||
log('Completing authorization...')
|
log('Completing authorization...')
|
||||||
await transport.finishAuth(code)
|
await transport.finishAuth(code)
|
||||||
|
|
||||||
// Create a new transport after auth
|
if (recursionReasons.has(REASON_AUTH_NEEDED)) {
|
||||||
const newTransport = new SSEClientTransport(url, { authProvider })
|
const errorMessage = `Already attempted reconnection for reason: ${REASON_AUTH_NEEDED}. Giving up.`
|
||||||
await newTransport.start()
|
log(errorMessage)
|
||||||
log('Connected to remote server after authentication')
|
throw new Error(errorMessage)
|
||||||
return newTransport
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} catch (authError) {
|
||||||
log('Authorization error:', authError)
|
log('Authorization error:', authError)
|
||||||
throw authError
|
throw authError
|
||||||
|
@ -112,10 +248,57 @@ export async function connectToRemoteServer(
|
||||||
* @param options The server options
|
* @param options The server options
|
||||||
* @returns An object with the server, authCode, and waitForAuthCode function
|
* @returns An object with the server, authCode, and waitForAuthCode function
|
||||||
*/
|
*/
|
||||||
export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
|
export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServerOptions) {
|
||||||
let authCode: string | null = null
|
let authCode: string | null = null
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
|
// Create a promise to track when auth is completed
|
||||||
|
let authCompletedResolve: (code: string) => void
|
||||||
|
const authCompletedPromise = new Promise<string>((resolve) => {
|
||||||
|
authCompletedResolve = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
// Long-polling endpoint
|
||||||
|
app.get('/wait-for-auth', (req, res) => {
|
||||||
|
if (authCode) {
|
||||||
|
// Auth already completed - just return 200 without the actual code
|
||||||
|
// Secondary instances will read tokens from disk
|
||||||
|
log('Auth already completed, returning 200')
|
||||||
|
res.status(200).send('Authentication completed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.poll === 'false') {
|
||||||
|
log('Client requested no long poll, responding with 202')
|
||||||
|
res.status(202).send('Authentication in progress')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long poll - wait for up to 30 seconds
|
||||||
|
const longPollTimeout = setTimeout(() => {
|
||||||
|
log('Long poll timeout reached, responding with 202')
|
||||||
|
res.status(202).send('Authentication in progress')
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
// If auth completes while we're waiting, send the response immediately
|
||||||
|
authCompletedPromise
|
||||||
|
.then(() => {
|
||||||
|
clearTimeout(longPollTimeout)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
log('Auth completed during long poll, responding with 200')
|
||||||
|
res.status(200).send('Authentication completed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
clearTimeout(longPollTimeout)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
log('Auth failed during long poll, responding with 500')
|
||||||
|
res.status(500).send('Authentication failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// OAuth callback endpoint
|
||||||
app.get(options.path, (req, res) => {
|
app.get(options.path, (req, res) => {
|
||||||
const code = req.query.code as string | undefined
|
const code = req.query.code as string | undefined
|
||||||
if (!code) {
|
if (!code) {
|
||||||
|
@ -124,7 +307,19 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
authCode = code
|
authCode = code
|
||||||
res.send('Authorization successful! You may close this window and return to the CLI.')
|
log('Auth code received, resolving promise')
|
||||||
|
authCompletedResolve(code)
|
||||||
|
|
||||||
|
res.send(`
|
||||||
|
Authorization successful!
|
||||||
|
You may close this window and return to the CLI.
|
||||||
|
<script>
|
||||||
|
// If this is a non-interactive session (no manual approval step was required) then
|
||||||
|
// this should automatically close the window. If not, this will have no effect and
|
||||||
|
// the user will see the message above.
|
||||||
|
window.close();
|
||||||
|
</script>
|
||||||
|
`)
|
||||||
|
|
||||||
// Notify main flow that auth code is available
|
// Notify main flow that auth code is available
|
||||||
options.events.emit('auth-code-received', code)
|
options.events.emit('auth-code-received', code)
|
||||||
|
@ -134,10 +329,6 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
|
||||||
log(`OAuth callback server running at http://127.0.0.1:${options.port}`)
|
log(`OAuth callback server running at http://127.0.0.1:${options.port}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Waits for the OAuth authorization code
|
|
||||||
* @returns A promise that resolves with the authorization code
|
|
||||||
*/
|
|
||||||
const waitForAuthCode = (): Promise<string> => {
|
const waitForAuthCode = (): Promise<string> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (authCode) {
|
if (authCode) {
|
||||||
|
@ -151,9 +342,40 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { server, authCode, waitForAuthCode, authCompletedPromise }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up an Express server to handle OAuth callbacks
|
||||||
|
* @param options The server options
|
||||||
|
* @returns An object with the server, authCode, and waitForAuthCode function
|
||||||
|
*/
|
||||||
|
export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
|
||||||
|
const { server, authCode, waitForAuthCode } = setupOAuthCallbackServerWithLongPoll(options)
|
||||||
return { server, authCode, waitForAuthCode }
|
return { server, authCode, waitForAuthCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findExistingClientPort(serverUrlHash: string): Promise<number | undefined> {
|
||||||
|
const clientInfo = await readJsonFile<OAuthClientInformationFull>(serverUrlHash, 'client_info.json', OAuthClientInformationFullSchema)
|
||||||
|
if (!clientInfo) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const localhostRedirectUri = clientInfo.redirect_uris.map((uri) => new URL(uri)).find(({ hostname }) => hostname === 'localhost')
|
||||||
|
if (!localhostRedirectUri) {
|
||||||
|
throw new Error('Cannot find localhost callback URI from existing client information')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseInt(localhostRedirectUri.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDefaultPort(serverUrlHash: string): number {
|
||||||
|
// Convert the first 4 bytes of the serverUrlHash into a port offset
|
||||||
|
const offset = parseInt(serverUrlHash.substring(0, 4), 16)
|
||||||
|
// Pick a consistent but random-seeming port from 3335 to 49151
|
||||||
|
return 3335 + (offset % 45816)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds an available port on the local machine
|
* Finds an available port on the local machine
|
||||||
* @param preferredPort Optional preferred port to try first
|
* @param preferredPort Optional preferred port to try first
|
||||||
|
@ -187,22 +409,45 @@ export async function findAvailablePort(preferredPort?: number): Promise<number>
|
||||||
/**
|
/**
|
||||||
* Parses command line arguments for MCP clients and proxies
|
* Parses command line arguments for MCP clients and proxies
|
||||||
* @param args Command line arguments
|
* @param args Command line arguments
|
||||||
* @param defaultPort Default port for the callback server if specified port is unavailable
|
|
||||||
* @param usage Usage message to show on error
|
* @param usage Usage message to show on error
|
||||||
* @returns A promise that resolves to an object with parsed serverUrl, callbackPort, and clean flag
|
* @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) {
|
||||||
// Check for --clean flag
|
// Process headers
|
||||||
const cleanIndex = args.indexOf('--clean')
|
const headers: Record<string, string> = {}
|
||||||
const clean = cleanIndex !== -1
|
let i = 0
|
||||||
|
while (i < args.length) {
|
||||||
// Remove the flag from args if it exists
|
if (args[i] === '--header' && i < args.length - 1) {
|
||||||
if (clean) {
|
const value = args[i + 1]
|
||||||
args.splice(cleanIndex, 1)
|
const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/)
|
||||||
|
if (match) {
|
||||||
|
headers[match[1]] = match[2]
|
||||||
|
} else {
|
||||||
|
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 serverUrl = args[0]
|
||||||
const specifiedPort = args[1] ? parseInt(args[1]) : undefined
|
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) {
|
if (!serverUrl) {
|
||||||
log(usage)
|
log(usage)
|
||||||
|
@ -212,25 +457,55 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
|
||||||
const url = new URL(serverUrl)
|
const url = new URL(serverUrl)
|
||||||
const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:'
|
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)
|
log(usage)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
const serverUrlHash = getServerUrlHash(serverUrl)
|
||||||
|
const defaultPort = calculateDefaultPort(serverUrlHash)
|
||||||
|
|
||||||
// Use the specified port, or find an available one
|
// Use the specified port, or the existing client port or fallback to find an available one
|
||||||
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort))
|
const [existingClientPort, availablePort] = await Promise.all([findExistingClientPort(serverUrlHash), findAvailablePort(defaultPort)])
|
||||||
|
let callbackPort: number
|
||||||
|
|
||||||
if (specifiedPort) {
|
if (specifiedPort) {
|
||||||
log(`Using specified callback port: ${callbackPort}`)
|
if (existingClientPort && specifiedPort !== existingClientPort) {
|
||||||
|
log(
|
||||||
|
`Warning! Specified callback port of ${specifiedPort}, which conflicts with existing client registration port ${existingClientPort}. Deleting existing client data to force reregistration.`,
|
||||||
|
)
|
||||||
|
await fs.rm(getConfigFilePath(serverUrlHash, 'client_info.json'))
|
||||||
|
}
|
||||||
|
log(`Using specified callback port: ${specifiedPort}`)
|
||||||
|
callbackPort = specifiedPort
|
||||||
|
} else if (existingClientPort) {
|
||||||
|
log(`Using existing client port: ${existingClientPort}`)
|
||||||
|
callbackPort = existingClientPort
|
||||||
} else {
|
} else {
|
||||||
log(`Using automatically selected callback port: ${callbackPort}`)
|
log(`Using automatically selected callback port: ${availablePort}`)
|
||||||
|
callbackPort = availablePort
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clean) {
|
if (Object.keys(headers).length > 0) {
|
||||||
log('Clean mode enabled: config files will be reset before reading')
|
log(`Using custom headers: ${JSON.stringify(headers)}`)
|
||||||
|
}
|
||||||
|
// Replace environment variables in headers
|
||||||
|
// example `Authorization: Bearer ${TOKEN}` will read process.env.TOKEN
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
headers[key] = value.replace(/\$\{([^}]+)}/g, (match, envVarName) => {
|
||||||
|
const envVarValue = process.env[envVarName]
|
||||||
|
|
||||||
|
if (envVarValue !== undefined) {
|
||||||
|
log(`Replacing ${match} with environment value in header '${key}'`)
|
||||||
|
return envVarValue
|
||||||
|
} else {
|
||||||
|
log(`Warning: Environment variable '${envVarName}' not found for header '${key}'.`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { serverUrl, callbackPort, clean }
|
return { serverUrl, callbackPort, headers, transportStrategy }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -246,6 +521,18 @@ export function setupSignalHandlers(cleanup: () => Promise<void>) {
|
||||||
|
|
||||||
// Keep the process alive
|
// Keep the process alive
|
||||||
process.stdin.resume()
|
process.stdin.resume()
|
||||||
|
process.stdin.on('end', async () => {
|
||||||
|
log('\nShutting down...')
|
||||||
|
await cleanup()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MCP_REMOTE_VERSION = require('../../package.json').version
|
/**
|
||||||
|
* Generates a hash for the server URL to use in filenames
|
||||||
|
* @param serverUrl The server URL to hash
|
||||||
|
* @returns The hashed server URL
|
||||||
|
*/
|
||||||
|
export function getServerUrlHash(serverUrl: string): string {
|
||||||
|
return crypto.createHash('md5').update(serverUrl).digest('hex')
|
||||||
|
}
|
||||||
|
|
78
src/proxy.ts
78
src/proxy.ts
|
@ -4,47 +4,81 @@
|
||||||
* MCP Proxy with OAuth support
|
* MCP Proxy with OAuth support
|
||||||
* A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication.
|
* A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication.
|
||||||
*
|
*
|
||||||
* Run with: npx tsx proxy.ts [--clean] https://example.remote/server [callback-port]
|
* Run with: npx tsx proxy.ts https://example.remote/server [callback-port]
|
||||||
*
|
|
||||||
* Options:
|
|
||||||
* --clean: Deletes stored configuration before reading, ensuring a fresh session
|
|
||||||
*
|
*
|
||||||
* If callback-port is not specified, an available port will be automatically selected.
|
* If callback-port is not specified, an available port will be automatically selected.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||||
import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers } 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 { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
|
||||||
|
import { createLazyAuthCoordinator } from './lib/coordination'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main function to run the proxy
|
* Main function to run the proxy
|
||||||
*/
|
*/
|
||||||
async function runProxy(serverUrl: string, callbackPort: number, clean: boolean = false) {
|
async function runProxy(
|
||||||
|
serverUrl: string,
|
||||||
|
callbackPort: number,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
transportStrategy: TransportStrategy = 'http-first',
|
||||||
|
) {
|
||||||
// Set up event emitter for auth flow
|
// Set up event emitter for auth flow
|
||||||
const events = new EventEmitter()
|
const events = new EventEmitter()
|
||||||
|
|
||||||
|
// Get the server URL hash for lockfile operations
|
||||||
|
const serverUrlHash = getServerUrlHash(serverUrl)
|
||||||
|
|
||||||
|
// Create a lazy auth coordinator
|
||||||
|
const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events)
|
||||||
|
|
||||||
// Create the OAuth client provider
|
// Create the OAuth client provider
|
||||||
const authProvider = new NodeOAuthClientProvider({
|
const authProvider = new NodeOAuthClientProvider({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
callbackPort,
|
callbackPort,
|
||||||
clientName: 'MCP CLI Proxy',
|
clientName: 'MCP CLI Proxy',
|
||||||
clean,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create the STDIO transport for local connections
|
// Create the STDIO transport for local connections
|
||||||
const localTransport = new StdioServerTransport()
|
const localTransport = new StdioServerTransport()
|
||||||
|
|
||||||
// Set up an HTTP server to handle OAuth callback
|
// Keep track of the server instance for cleanup
|
||||||
const { server, waitForAuthCode } = setupOAuthCallbackServer({
|
let server: any = null
|
||||||
port: callbackPort,
|
|
||||||
path: '/oauth/callback',
|
// Define an auth initializer function
|
||||||
events,
|
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 {
|
try {
|
||||||
// Connect to remote server with authentication
|
// Connect to remote server with lazy authentication
|
||||||
const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, waitForAuthCode)
|
const remoteTransport = await connectToRemoteServer(null, serverUrl, authProvider, headers, authInitializer, transportStrategy)
|
||||||
|
|
||||||
// Set up bidirectional proxy between local and remote transports
|
// Set up bidirectional proxy between local and remote transports
|
||||||
mcpProxy({
|
mcpProxy({
|
||||||
|
@ -55,15 +89,18 @@ async function runProxy(serverUrl: string, callbackPort: number, clean: boolean
|
||||||
// Start the local STDIO server
|
// Start the local STDIO server
|
||||||
await localTransport.start()
|
await localTransport.start()
|
||||||
log('Local STDIO server running')
|
log('Local STDIO server running')
|
||||||
log('Proxy established successfully between local STDIO and remote SSE')
|
log(`Proxy established successfully between local STDIO and remote ${remoteTransport.constructor.name}`)
|
||||||
log('Press Ctrl+C to exit')
|
log('Press Ctrl+C to exit')
|
||||||
|
|
||||||
// Setup cleanup handler
|
// Setup cleanup handler
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
await remoteTransport.close()
|
await remoteTransport.close()
|
||||||
await localTransport.close()
|
await localTransport.close()
|
||||||
|
// Only close the server if it was initialized
|
||||||
|
if (server) {
|
||||||
server.close()
|
server.close()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setupSignalHandlers(cleanup)
|
setupSignalHandlers(cleanup)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('Fatal error:', error)
|
log('Fatal error:', error)
|
||||||
|
@ -89,15 +126,18 @@ to the CA certificate file. If using claude_desktop_config.json, this might look
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
// Only close the server if it was initialized
|
||||||
|
if (server) {
|
||||||
server.close()
|
server.close()
|
||||||
|
}
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse command-line arguments and run the proxy
|
// Parse command-line arguments and run the proxy
|
||||||
parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [--clean] <https://server-url> [callback-port]')
|
parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]')
|
||||||
.then(({ serverUrl, callbackPort, clean }) => {
|
.then(({ serverUrl, callbackPort, headers, transportStrategy }) => {
|
||||||
return runProxy(serverUrl, callbackPort, clean)
|
return runProxy(serverUrl, callbackPort, headers, transportStrategy)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
log('Fatal error:', error)
|
log('Fatal error:', error)
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"types": ["node", "react"],
|
"types": ["node"],
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue