diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..005fbc3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 diff --git a/README.md b/README.md index 7acaa85..bc0a4a7 100644 --- a/README.md +++ b/README.md @@ -10,84 +10,151 @@ So far, the majority of MCP servers in the wild are installed locally, using the But there's a reason most software that _could_ be moved to the web _did_ get moved to the web: it's so much easier to find and fix bugs & iterate on new features when you can push updates to all your users with a single deploy. -With the MCP [Authorization specification](https://spec.modelcontextprotocol.io/specification/draft/basic/authorization/) nearing completion, we now have a secure way of sharing our MCP servers with the world _without_ running code on user's laptops. Or at least, you would, if all the popular MCP _clients_ supported it yet. Most are stdio-only, and those that _do_ support HTTP+SSE don't yet support the OAuth flows required. +With the latest MCP [Authorization specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization), we now have a secure way of sharing our MCP servers with the world _without_ running code on user's laptops. Or at least, you would, if all the popular MCP _clients_ supported it yet. Most are stdio-only, and those that _do_ support HTTP+SSE don't yet support the OAuth flows required. That's where `mcp-remote` comes in. As soon as your chosen MCP client supports remote, authorized servers, you can remove it. Until that time, drop in this one liner and dress for the MCP clients you want! ## Usage +All the most popular MCP clients (Claude Desktop, Cursor & Windsurf) use the following config format: + +```json +{ + "mcpServers": { + "remote-example": { + "command": "npx", + "args": [ + "mcp-remote", + "https://remote.mcp.server/sse" + ] + } + } +} +``` + +### 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 " // spaces OK in env vars + } +}, +``` + +### Flags + +* If `npx` is producing errors, consider adding `-y` as the first argument to auto-accept the installation of the `mcp-remote` package. + +```json + "command": "npx", + "args": [ + "-y" + "mcp-remote", + "https://remote.mcp.server/sse" + ] +``` + +* To force `npx` to always check for an updated version of `mcp-remote`, add the `@latest` flag: + +```json + "args": [ + "mcp-remote@latest", + "https://remote.mcp.server/sse" + ] +``` + +* 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 + "args": [ + "mcp-remote", + "https://remote.mcp.server/sse", + "9696" + ] +``` + +* To allow HTTP connections in trusted private networks, add the `--allow-http` flag. Note: This should only be used in secure private networks where traffic cannot be intercepted. + +```json + "args": [ + "mcp-remote", + "http://internal-service.vpc/sse", + "--allow-http" + ] +``` + +### Transport Strategies + +MCP Remote supports different transport strategies when connecting to an MCP server. This allows you to control whether it uses Server-Sent Events (SSE) or HTTP transport, and in what order it tries them. + +Specify the transport strategy with the `--transport` flag: + +```bash +npx mcp-remote https://example.remote/server --transport sse-only +``` + +**Available Strategies:** + +- `http-first` (default): Tries HTTP transport first, falls back to SSE if HTTP fails with a 404 error +- `sse-first`: Tries SSE transport first, falls back to HTTP if SSE fails with a 405 error +- `http-only`: Only uses HTTP transport, fails if the server doesn't support it +- `sse-only`: Only uses SSE transport, fails if the server doesn't support it + ### Claude Desktop [Official Docs](https://modelcontextprotocol.io/quickstart/user) In order to add an MCP server to Claude Desktop you need to edit the configuration file located at: -macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - -Windows: `%APPDATA%\Claude\claude_desktop_config.json` +* macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +* Windows: `%APPDATA%\Claude\claude_desktop_config.json` If it does not exist yet, [you may need to enable it under Settings > Developer](https://modelcontextprotocol.io/quickstart/user#2-add-the-filesystem-mcp-server). -```json -{ - "mcpServers": { - "remote-example": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://remote.mcp.server/sse" - ] - } - } -} -``` - Restart Claude Desktop to pick up the changes in the configuration file. Upon restarting, you should see a hammer icon in the bottom right corner of the input box. ### Cursor -[Official Docs](https://docs.cursor.com/context/model-context-protocol) +[Official Docs](https://docs.cursor.com/context/model-context-protocol). The configuration file is located at `~/.cursor/mcp.json`. -Add the following configuration to `~/.cursor/mcp.json`: - -```json -{ - "mcpServers": { - "remote-example": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://remote.mcp.server/sse" - ] - } - } -} -``` +As of version `0.48.0`, Cursor supports unauthed SSE servers directly. If your MCP server is using the official MCP OAuth authorization protocol, you still need to add a **"command"** server and call `mcp-remote`. ### Windsurf -[Official Docs](https://docs.codeium.com/windsurf/mcp) - -Add the following configuration to `~/.codeium/windsurf/mcp_config.json`: - -```json -{ - "mcpServers": { - "remote-example": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://remote.mcp.server/sse" - ] - } - } -} -``` +[Official Docs](https://docs.codeium.com/windsurf/mcp). The configuration file is located at `~/.codeium/windsurf/mcp_config.json`. ## Building Remote MCP Servers @@ -106,11 +173,21 @@ For more information about testing these servers, see also: Know of more resources you'd like to share? Please add them to this Readme and send a PR! -## Debugging +## Troubleshooting + +### Clear your `~/.mcp-auth` directory + +`mcp-remote` stores all the credential information inside `~/.mcp-auth` (or wherever your `MCP_REMOTE_CONFIG_DIR` points to). If you're having persistent issues, try running: + +```sh +rm -rf ~/.mcp-auth +``` + +Then restarting your MCP client. ### 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 Desktop will use your system version of Node, even if you have a newer version installed elsewhere. @@ -141,3 +218,31 @@ this might look like: } } ``` + +### Check the logs + +* [Follow Claude Desktop logs in real-time](https://modelcontextprotocol.io/docs/tools/debugging#debugging-in-claude-desktop) +* MacOS / Linux:
`tail -n 20 -F ~/Library/Logs/Claude/mcp*.log` +* For bash on WSL:
`tail -n 20 -f "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log"` +* Powershell:
`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 + +Run the following on the command line (not from an MCP server): + +```shell +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. 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. diff --git a/package.json b/package.json index e9f0be6..bf4892f 100644 --- a/package.json +++ b/package.json @@ -1,47 +1,50 @@ { - "name": "mcp-remote", - "version": "0.0.10", + "name": "@kvant/mcp-remote", + "version": "0.1.5", + "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", + "keywords": [ + "mcp", + "stdio", + "sse", + "remote", + "oauth" + ], + "author": "Glen Maddern ", + "repository": "https://github.com/geelen/mcp-remote", "type": "module", - "bin": { - "mcp-remote": "dist/cli/proxy.js" - }, "files": [ "dist", "README.md", "LICENSE" ], - "exports": { - "./react": { - "types": "./dist/react/index.d.ts", - "require": "./dist/react/index.js", - "import": "./dist/react/index.js" - } + "main": "dist/index.js", + "bin": { + "mcp-remote": "dist/proxy.js", + "mcp-remote-client": "dist/client.js" }, "scripts": { - "dev": "tsup --watch", "build": "tsup", + "build:watch": "tsup --watch", "check": "prettier --check . && tsc" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.7.0", "express": "^4.21.2", "open": "^10.1.0" }, + "packageManager": "pnpm@10.11.0", "devDependencies": { + "@modelcontextprotocol/sdk": "^1.11.2", "@types/express": "^5.0.0", "@types/node": "^22.13.10", - "@types/react": "^19.0.12", "prettier": "^3.5.3", - "react": "^19.0.0", "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.8.2" }, "tsup": { "entry": [ - "src/cli/client.ts", - "src/cli/proxy.ts", - "src/react/index.ts" + "src/client.ts", + "src/proxy.ts" ], "format": [ "esm" @@ -49,8 +52,6 @@ "dts": true, "clean": true, "outDir": "dist", - "external": [ - "react" - ] + "external": [] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74a123e..d0720bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.7.0 - version: 1.7.0 express: specifier: ^4.21.2 version: 4.21.2 @@ -18,21 +15,18 @@ importers: specifier: ^10.1.0 version: 10.1.0 devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.11.2 + version: 1.11.2 '@types/express': specifier: ^5.0.0 version: 5.0.0 '@types/node': specifier: ^22.13.10 version: 22.13.10 - '@types/react': - specifier: ^19.0.12 - version: 19.0.12 prettier: specifier: ^3.5.3 version: 3.5.3 - react: - specifier: ^19.0.0 - version: 19.0.0 tsup: specifier: ^8.4.0 version: 8.4.0(tsx@4.19.3)(typescript@5.8.2) @@ -217,8 +211,8 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@modelcontextprotocol/sdk@1.7.0': - resolution: {integrity: sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==} + '@modelcontextprotocol/sdk@1.11.2': + resolution: {integrity: sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==} engines: {node: '>=18'} '@pkgjs/parseargs@0.11.0': @@ -350,9 +344,6 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react@19.0.12': - resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==} - '@types/send@0.17.4': resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} @@ -396,8 +387,8 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@2.1.0: - resolution: {integrity: sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==} + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} brace-expansion@2.0.1: @@ -479,9 +470,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -490,15 +478,6 @@ packages: supports-color: optional: true - debug@4.3.6: - resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -576,12 +555,12 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventsource-parser@3.0.0: - resolution: {integrity: sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==} + eventsource-parser@3.0.1: + resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==} engines: {node: '>=18.0.0'} - eventsource@3.0.5: - resolution: {integrity: sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==} + eventsource@3.0.6: + resolution: {integrity: sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==} engines: {node: '>=18.0.0'} express-rate-limit@7.5.0: @@ -594,8 +573,8 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} - express@5.0.1: - resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==} + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} fdir@6.4.3: @@ -673,10 +652,6 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - iconv-lite@0.5.2: - resolution: {integrity: sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==} - engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -771,8 +746,8 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime-types@3.0.0: - resolution: {integrity: sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==} + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} mime@1.6.0: @@ -791,9 +766,6 @@ packages: ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -860,8 +832,8 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} - pkce-challenge@4.1.0: - resolution: {integrity: sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==} + pkce-challenge@5.0.0: + resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} engines: {node: '>=16.20.0'} postcss-load-config@6.0.1: @@ -915,10 +887,6 @@ packages: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} - react@19.0.0: - resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} - engines: {node: '>=0.10.0'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -935,8 +903,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - router@2.1.0: - resolution: {integrity: sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} run-applescript@7.0.0: @@ -953,16 +921,16 @@ packages: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} - send@1.1.0: - resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==} + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} serve-static@1.16.2: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} - serve-static@2.1.0: - resolution: {integrity: sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==} + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} setprototypeof@1.2.0: @@ -1081,8 +1049,8 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} - type-is@2.0.0: - resolution: {integrity: sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} typescript@5.8.2: @@ -1132,8 +1100,8 @@ packages: peerDependencies: zod: ^3.24.1 - zod@3.24.2: - resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@3.24.3: + resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} snapshots: @@ -1238,17 +1206,18 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@modelcontextprotocol/sdk@1.7.0': + '@modelcontextprotocol/sdk@1.11.2': dependencies: content-type: 1.0.5 cors: 2.8.5 - eventsource: 3.0.5 - express: 5.0.1 - express-rate-limit: 7.5.0(express@5.0.1) - pkce-challenge: 4.1.0 + cross-spawn: 7.0.6 + eventsource: 3.0.6 + express: 5.1.0 + express-rate-limit: 7.5.0(express@5.1.0) + pkce-challenge: 5.0.0 raw-body: 3.0.0 - zod: 3.24.2 - zod-to-json-schema: 3.24.5(zod@3.24.2) + zod: 3.24.3 + zod-to-json-schema: 3.24.5(zod@3.24.3) transitivePeerDependencies: - supports-color @@ -1349,10 +1318,6 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react@19.0.12': - dependencies: - csstype: 3.1.3 - '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 @@ -1371,7 +1336,7 @@ snapshots: accepts@2.0.0: dependencies: - mime-types: 3.0.0 + mime-types: 3.0.1 negotiator: 1.0.0 ansi-regex@5.0.1: {} @@ -1407,17 +1372,17 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.1.0: + body-parser@2.2.0: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.0 http-errors: 2.0.0 - iconv-lite: 0.5.2 + iconv-lite: 0.6.3 on-finished: 2.4.1 qs: 6.14.0 raw-body: 3.0.0 - type-is: 2.0.0 + type-is: 2.0.1 transitivePeerDependencies: - supports-color @@ -1489,16 +1454,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - csstype@3.1.3: {} - debug@2.6.9: dependencies: ms: 2.0.0 - debug@4.3.6: - dependencies: - ms: 2.1.2 - debug@4.4.0: dependencies: ms: 2.1.3 @@ -1574,15 +1533,15 @@ snapshots: etag@1.8.1: {} - eventsource-parser@3.0.0: {} + eventsource-parser@3.0.1: {} - eventsource@3.0.5: + eventsource@3.0.6: dependencies: - eventsource-parser: 3.0.0 + eventsource-parser: 3.0.1 - express-rate-limit@7.5.0(express@5.0.1): + express-rate-limit@7.5.0(express@5.1.0): dependencies: - express: 5.0.1 + express: 5.1.0 express@4.21.2: dependencies: @@ -1620,16 +1579,15 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.0.1: + express@5.1.0: dependencies: accepts: 2.0.0 - body-parser: 2.1.0 + body-parser: 2.2.0 content-disposition: 1.0.0 content-type: 1.0.5 cookie: 0.7.1 cookie-signature: 1.2.2 - debug: 4.3.6 - depd: 2.0.0 + debug: 4.4.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -1637,22 +1595,18 @@ snapshots: fresh: 2.0.0 http-errors: 2.0.0 merge-descriptors: 2.0.0 - methods: 1.1.2 - mime-types: 3.0.0 + mime-types: 3.0.1 on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.13.0 + qs: 6.14.0 range-parser: 1.2.1 - router: 2.1.0 - safe-buffer: 5.2.1 - send: 1.1.0 - serve-static: 2.1.0 - setprototypeof: 1.2.0 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 statuses: 2.0.1 - type-is: 2.0.0 - utils-merge: 1.0.1 + type-is: 2.0.1 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -1751,10 +1705,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.5.2: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -1817,7 +1767,7 @@ snapshots: dependencies: mime-db: 1.52.0 - mime-types@3.0.0: + mime-types@3.0.1: dependencies: mime-db: 1.54.0 @@ -1831,8 +1781,6 @@ snapshots: ms@2.0.0: {} - ms@2.1.2: {} - ms@2.1.3: {} mz@2.7.0: @@ -1885,7 +1833,7 @@ snapshots: pirates@4.0.6: {} - pkce-challenge@4.1.0: {} + pkce-challenge@5.0.0: {} postcss-load-config@6.0.1(tsx@4.19.3): dependencies: @@ -1926,8 +1874,6 @@ snapshots: iconv-lite: 0.6.3 unpipe: 1.0.0 - react@19.0.0: {} - readdirp@4.1.2: {} resolve-from@5.0.0: {} @@ -1959,11 +1905,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.35.0 fsevents: 2.3.3 - router@2.1.0: + router@2.2.0: dependencies: + debug: 4.4.0 + depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color run-applescript@7.0.0: {} @@ -1989,16 +1939,15 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.1.0: + send@1.2.0: dependencies: debug: 4.4.0 - destroy: 1.2.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - fresh: 0.5.2 + fresh: 2.0.0 http-errors: 2.0.0 - mime-types: 2.1.35 + mime-types: 3.0.1 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 @@ -2015,12 +1964,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.1.0: + serve-static@2.2.0: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.1.0 + send: 1.2.0 transitivePeerDependencies: - supports-color @@ -2161,11 +2110,11 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 - type-is@2.0.0: + type-is@2.0.1: dependencies: content-type: 1.0.5 media-typer: 1.1.0 - mime-types: 3.0.0 + mime-types: 3.0.1 typescript@5.8.2: {} @@ -2203,8 +2152,8 @@ snapshots: wrappy@1.0.2: {} - zod-to-json-schema@3.24.5(zod@3.24.2): + zod-to-json-schema@3.24.5(zod@3.24.3): dependencies: - zod: 3.24.2 + zod: 3.24.3 - zod@3.24.2: {} + zod@3.24.3: {} diff --git a/src/cli/client.ts b/src/cli/client.ts deleted file mode 100644 index f79e917..0000000 --- a/src/cli/client.ts +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env node - -/** - * MCP Client with OAuth support - * A command-line client that connects to an MCP server using SSE with OAuth authentication. - * - * Run with: npx tsx client.ts https://example.remote/server [callback-port] - * - * If callback-port is not specified, an available port will be automatically selected. - */ - -import { EventEmitter } from 'events' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' -import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' -import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js' - -/** - * Main function to run the client - */ -async function runClient(serverUrl: string, callbackPort: number) { - // Set up event emitter for auth flow - const events = new EventEmitter() - - // Create the OAuth client provider - const authProvider = new NodeOAuthClientProvider({ - serverUrl, - callbackPort, - clientName: 'MCP CLI Client', - }) - - // Create the client - const client = new Client( - { - name: 'mcp-cli', - version: '0.1.0', - }, - { - capabilities: { - sampling: {}, - }, - }, - ) - - // Create the transport factory - const url = new URL(serverUrl) - function initTransport() { - const transport = new SSEClientTransport(url, { authProvider }) - - // Set up message and error handlers - transport.onmessage = (message) => { - console.log('Received message:', JSON.stringify(message, null, 2)) - } - - transport.onerror = (error) => { - console.error('Transport error:', error) - } - - transport.onclose = () => { - console.log('Connection closed.') - 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 - const cleanup = async () => { - console.log('\nClosing connection...') - await client.close() - server.close() - } - setupSignalHandlers(cleanup) - - // Try to connect - 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 { - // Request tools list - console.log('Requesting tools list...') - const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) - console.log('Tools:', JSON.stringify(tools, null, 2)) - } catch (e) { - console.log('Error requesting tools list:', e) - } - - try { - // Request resources list - console.log('Requesting resource list...') - const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) - console.log('Resources:', JSON.stringify(resources, null, 2)) - } catch (e) { - console.log('Error requesting resources list:', e) - } - - console.log('Listening for messages. Press Ctrl+C to exit.') -} - -// Parse command-line arguments and run the client -parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [callback-port]') - .then(({ serverUrl, callbackPort }) => { - return runClient(serverUrl, callbackPort) - }) - .catch((error) => { - console.error('Fatal error:', error) - process.exit(1) - }) diff --git a/src/cli/proxy.ts b/src/cli/proxy.ts deleted file mode 100644 index d705b35..0000000 --- a/src/cli/proxy.ts +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node - -/** - * MCP Proxy with OAuth support - * A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication. - * - * Run with: npx tsx proxy.ts https://example.remote/server [callback-port] - * - * If callback-port is not specified, an available port will be automatically selected. - */ - -import { EventEmitter } from 'events' -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js' -import { connectToRemoteServer, mcpProxy } from '../lib/utils.js' - -/** - * Main function to run the proxy - */ -async function runProxy(serverUrl: string, callbackPort: number) { - // Set up event emitter for auth flow - const events = new EventEmitter() - - // Create the OAuth client provider - const authProvider = new NodeOAuthClientProvider({ - serverUrl, - callbackPort, - clientName: 'MCP CLI Proxy', - }) - - // Create the STDIO transport for local connections - const localTransport = new StdioServerTransport() - - // Set up an HTTP server to handle OAuth callback - const { server, waitForAuthCode } = setupOAuthCallbackServer({ - port: callbackPort, - path: '/oauth/callback', - events, - }) - - try { - // Connect to remote server with authentication - const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, waitForAuthCode) - - // Set up bidirectional proxy between local and remote transports - mcpProxy({ - transportToClient: localTransport, - transportToServer: remoteTransport, - }) - - // Start the local STDIO server - await localTransport.start() - console.error('Local STDIO server running') - console.error('Proxy established successfully between local STDIO and remote SSE') - console.error('Press Ctrl+C to exit') - - // Setup cleanup handler - const cleanup = async () => { - await remoteTransport.close() - await localTransport.close() - server.close() - } - setupSignalHandlers(cleanup) - } catch (error) { - console.error('Fatal error:', error) - if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) { - console.error(`You may be behind a VPN! - -If you are behind a VPN, you can try setting the NODE_EXTRA_CA_CERTS environment variable to point -to the CA certificate file. If using claude_desktop_config.json, this might look like: - -{ - "mcpServers": { - "\${mcpServerName}": { - "command": "npx", - "args": [ - "mcp-remote", - "https://remote.mcp.server/sse" - ], - "env": { - "NODE_EXTRA_CA_CERTS": "\${your CA certificate file path}.pem" - } - } - } -} - `) - } - server.close() - process.exit(1) - } -} - -// Parse command-line arguments and run the proxy -parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [callback-port]') - .then(({ serverUrl, callbackPort }) => { - return runProxy(serverUrl, callbackPort) - }) - .catch((error) => { - console.error('Fatal error:', error) - process.exit(1) - }) diff --git a/src/cli/shared.ts b/src/cli/shared.ts deleted file mode 100644 index b146abd..0000000 --- a/src/cli/shared.ts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Shared utilities for MCP OAuth clients and proxies. - * Contains common functionality for authentication, file storage, and proxying. - */ - -import express from 'express' -import open from 'open' -import fs from 'fs/promises' -import path from 'path' -import os from 'os' -import crypto from 'crypto' -import net from 'net' -import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' -import { - OAuthClientInformation, - OAuthClientInformationFull, - OAuthClientInformationSchema, - OAuthTokens, - OAuthTokensSchema, -} from '@modelcontextprotocol/sdk/shared/auth.js' -import { OAuthCallbackServerOptions, OAuthProviderOptions } from '../lib/types.js' - -/** - * Implements the OAuthClientProvider interface for Node.js environments. - * Handles OAuth flow and token storage for MCP clients. - */ -export class NodeOAuthClientProvider implements OAuthClientProvider { - private configDir: string - private serverUrlHash: string - private callbackPath: string - private clientName: string - private clientUri: string - - /** - * Creates a new NodeOAuthClientProvider - * @param options Configuration options for the provider - */ - constructor(readonly options: OAuthProviderOptions) { - this.serverUrlHash = crypto.createHash('md5').update(options.serverUrl).digest('hex') - this.configDir = options.configDir || path.join(os.homedir(), '.mcp-auth') - this.callbackPath = options.callbackPath || '/oauth/callback' - this.clientName = options.clientName || 'MCP CLI Client' - this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli' - } - - get redirectUrl(): string { - return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}` - } - - get clientMetadata() { - return { - redirect_uris: [this.redirectUrl], - token_endpoint_auth_method: 'none', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - client_name: this.clientName, - client_uri: this.clientUri, - } - } - - /** - * Ensures the configuration directory exists - * @private - */ - private async ensureConfigDir() { - try { - await fs.mkdir(this.configDir, { recursive: true }) - } catch (error) { - console.error('Error creating config directory:', error) - throw error - } - } - - /** - * Reads a JSON file and parses it with the provided schema - * @param filename The name of the file to read - * @param schema The schema to validate against - * @returns The parsed file content or undefined if the file doesn't exist - * @private - */ - private async readFile(filename: string, schema: any): Promise { - try { - await this.ensureConfigDir() - const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`) - const content = await fs.readFile(filePath, 'utf-8') - return await schema.parseAsync(JSON.parse(content)) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return undefined - } - return undefined - } - } - - /** - * Writes a JSON object to a file - * @param filename The name of the file to write - * @param data The data to write - * @private - */ - private async writeFile(filename: string, data: any) { - try { - await this.ensureConfigDir() - const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`) - await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8') - } catch (error) { - console.error(`Error writing ${filename}:`, error) - throw error - } - } - - /** - * Writes a text string to a file - * @param filename The name of the file to write - * @param text The text to write - * @private - */ - private async writeTextFile(filename: string, text: string) { - try { - await this.ensureConfigDir() - const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`) - await fs.writeFile(filePath, text, 'utf-8') - } catch (error) { - console.error(`Error writing ${filename}:`, error) - throw error - } - } - - /** - * Reads text from a file - * @param filename The name of the file to read - * @returns The file content as a string - * @private - */ - private async readTextFile(filename: string): Promise { - try { - await this.ensureConfigDir() - const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`) - return await fs.readFile(filePath, 'utf-8') - } catch (error) { - throw new Error('No code verifier saved for session') - } - } - - /** - * Gets the client information if it exists - * @returns The client information or undefined - */ - async clientInformation(): Promise { - return this.readFile('client_info.json', OAuthClientInformationSchema) - } - - /** - * Saves client information - * @param clientInformation The client information to save - */ - async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise { - await this.writeFile('client_info.json', clientInformation) - } - - /** - * Gets the OAuth tokens if they exist - * @returns The OAuth tokens or undefined - */ - async tokens(): Promise { - return this.readFile('tokens.json', OAuthTokensSchema) - } - - /** - * Saves OAuth tokens - * @param tokens The tokens to save - */ - async saveTokens(tokens: OAuthTokens): Promise { - await this.writeFile('tokens.json', tokens) - } - - /** - * Redirects the user to the authorization URL - * @param authorizationUrl The URL to redirect to - */ - async redirectToAuthorization(authorizationUrl: URL): Promise { - console.error(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`) - try { - await open(authorizationUrl.toString()) - console.error('Browser opened automatically.') - } catch (error) { - console.error('Could not open browser automatically. Please copy and paste the URL above into your browser.') - } - } - - /** - * Saves the PKCE code verifier - * @param codeVerifier The code verifier to save - */ - async saveCodeVerifier(codeVerifier: string): Promise { - await this.writeTextFile('code_verifier.txt', codeVerifier) - } - - /** - * Gets the PKCE code verifier - * @returns The code verifier - */ - async codeVerifier(): Promise { - return await this.readTextFile('code_verifier.txt') - } -} - -/** - * 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) { - let authCode: string | null = null - const app = express() - - app.get(options.path, (req, res) => { - const code = req.query.code as string | undefined - if (!code) { - res.status(400).send('Error: No authorization code received') - return - } - - authCode = code - res.send('Authorization successful! You may close this window and return to the CLI.') - - // Notify main flow that auth code is available - options.events.emit('auth-code-received', code) - }) - - const server = app.listen(options.port, () => { - console.error(`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 => { - return new Promise((resolve) => { - if (authCode) { - resolve(authCode) - return - } - - options.events.once('auth-code-received', (code) => { - resolve(code) - }) - }) - } - - return { server, authCode, waitForAuthCode } -} - -/** - * Finds an available port on the local machine - * @param preferredPort Optional preferred port to try first - * @returns A promise that resolves to an available port number - */ -export async function findAvailablePort(preferredPort?: number): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer() - - server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - // If preferred port is in use, get a random port - server.listen(0) - } else { - reject(err) - } - }) - - server.on('listening', () => { - const { port } = server.address() as net.AddressInfo - server.close(() => { - resolve(port) - }) - }) - - // Try preferred port first, or get a random port - server.listen(preferredPort || 0) - }) -} - -/** - * Parses command line arguments for MCP clients and proxies - * @param args Command line arguments - * @param defaultPort Default port for the callback server if specified port is unavailable - * @param usage Usage message to show on error - * @returns A promise that resolves to an object with parsed serverUrl and callbackPort - */ -export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) { - const serverUrl = args[0] - const specifiedPort = args[1] ? parseInt(args[1]) : undefined - - if (!serverUrl) { - console.error(usage) - process.exit(1) - } - - const url = new URL(serverUrl) - const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:' - - if (!(url.protocol == 'https:' || isLocalhost)) { - console.error(usage) - process.exit(1) - } - - // Use the specified port, or find an available one - const callbackPort = specifiedPort || (await findAvailablePort(defaultPort)) - - if (specifiedPort) { - console.error(`Using specified callback port: ${callbackPort}`) - } else { - console.error(`Using automatically selected callback port: ${callbackPort}`) - } - - return { serverUrl, callbackPort } -} - -/** - * Sets up signal handlers for graceful shutdown - * @param cleanup Cleanup function to run on shutdown - */ -export function setupSignalHandlers(cleanup: () => Promise) { - process.on('SIGINT', async () => { - console.error('\nShutting down...') - await cleanup() - process.exit(0) - }) - - // Keep the process alive - process.stdin.resume() -} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..d87599c --- /dev/null +++ b/src/client.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env node + +/** + * MCP Client with OAuth support + * A command-line client that connects to an MCP server using SSE with OAuth authentication. + * + * Run with: npx tsx client.ts https://example.remote/server [callback-port] + * + * If callback-port is not specified, an available port will be automatically selected. + */ + +import { EventEmitter } from 'events' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' +import { + parseCommandLineArgs, + setupSignalHandlers, + log, + MCP_REMOTE_VERSION, + getServerUrlHash, + connectToRemoteServer, + TransportStrategy, +} from './lib/utils' +import { createLazyAuthCoordinator } from './lib/coordination' + +/** + * Main function to run the client + */ +async function runClient( + serverUrl: string, + callbackPort: number, + headers: Record, + transportStrategy: TransportStrategy = 'http-first', +) { + // Set up event emitter for auth flow + const events = new EventEmitter() + + // Get the server URL hash for lockfile operations + const serverUrlHash = getServerUrlHash(serverUrl) + + // Create a lazy auth coordinator + const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events) + + // Create the OAuth client provider + const authProvider = new NodeOAuthClientProvider({ + serverUrl, + callbackPort, + clientName: 'MCP CLI Client', + }) + + // Create the client + const client = new Client( + { + name: 'mcp-remote', + version: MCP_REMOTE_VERSION, + }, + { + capabilities: {}, + }, + ) + + // Keep track of the server instance for cleanup + let server: any = null + + // Define an auth initializer function + const authInitializer = async () => { + const authState = await authCoordinator.initializeAuth() + + // Store server in outer scope for cleanup + server = authState.server + + // If auth was completed by another instance, just log that we'll use the auth from disk + if (authState.skipBrowserAuth) { + log('Authentication was completed by another instance - will use tokens from disk...') + // TODO: remove, the callback is happening before the tokens are exchanged + // so we're slightly too early + await new Promise((res) => setTimeout(res, 1_000)) + } + + return { + waitForAuthCode: authState.waitForAuthCode, + skipBrowserAuth: authState.skipBrowserAuth, + } + } + + try { + // Connect to remote server with lazy authentication + const transport = await connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy) + + // Set up message and error handlers + transport.onmessage = (message) => { + log('Received message:', JSON.stringify(message, null, 2)) + } + + transport.onerror = (error) => { + log('Transport error:', error) + } + + transport.onclose = () => { + log('Connection closed.') + process.exit(0) + } + + // Set up cleanup handler + const cleanup = async () => { + log('\nClosing connection...') + await client.close() + // If auth was initialized and server was created, close it + if (server) { + server.close() + } + } + setupSignalHandlers(cleanup) + + log('Connected successfully!') + + try { + // Request tools list + log('Requesting tools list...') + const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) + log('Tools:', JSON.stringify(tools, null, 2)) + } catch (e) { + log('Error requesting tools list:', e) + } + + try { + // Request resources list + log('Requesting resource list...') + const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) + log('Resources:', JSON.stringify(resources, null, 2)) + } catch (e) { + log('Error requesting resources list:', e) + } + + // log('Listening for messages. Press Ctrl+C to exit.') + log('Exiting OK...') + // Only close the server if it was initialized + if (server) { + server.close() + } + process.exit(0) + } catch (error) { + log('Fatal error:', error) + // Only close the server if it was initialized + if (server) { + server.close() + } + process.exit(1) + } +} + +// Parse command-line arguments and run the client +parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts [callback-port]') + .then(({ serverUrl, callbackPort, headers, transportStrategy }) => { + return runClient(serverUrl, callbackPort, headers, transportStrategy) + }) + .catch((error) => { + console.error('Fatal error:', error) + process.exit(1) + }) diff --git a/src/lib/coordination.ts b/src/lib/coordination.ts new file mode 100644 index 0000000..ffe0c5b --- /dev/null +++ b/src/lib/coordination.ts @@ -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; 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 { + 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 { + // 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 { + 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; 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; 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(() => {}) + } + + 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, + } +} diff --git a/src/lib/mcp-auth-config.ts b/src/lib/mcp-auth-config.ts new file mode 100644 index 0000000..1286b03 --- /dev/null +++ b/src/lib/mcp-auth-config.ts @@ -0,0 +1,206 @@ +import path from 'path' +import os from 'os' +import fs from 'fs/promises' +import { log, MCP_REMOTE_VERSION } from './utils' + +/** + * MCP Remote Authentication Configuration + * + * This module handles the storage and retrieval of authentication-related data for MCP Remote. + * + * Configuration directory structure: + * - The config directory is determined by MCP_REMOTE_CONFIG_DIR env var or defaults to ~/.mcp-auth + * - Each file is prefixed with a hash of the server URL to separate configurations for different servers + * + * Files stored in the config directory: + * - {server_hash}_client_info.json: Contains OAuth client registration information + * - Format: OAuthClientInformation object with client_id and other registration details + * - {server_hash}_tokens.json: Contains OAuth access and refresh tokens + * - Format: OAuthTokens object with access_token, refresh_token, and expiration information + * - {server_hash}_code_verifier.txt: Contains the PKCE code verifier for the current OAuth flow + * - Format: Plain text string used for PKCE verification + * + * All JSON files are stored with 2-space indentation for readability. + */ + +/** + * Lockfile data structure + */ +export interface LockfileData { + pid: number + port: number + timestamp: number +} + +/** + * 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 { + 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 { + try { + const lockfile = await readJsonFile(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 + */ +export async function deleteLockfile(serverUrlHash: string): Promise { + await deleteConfigFile(serverUrlHash, 'lock.json') +} + +/** + * Gets the configuration directory path + * @returns The path to the configuration directory + */ +export function getConfigDir(): string { + const baseConfigDir = process.env.MCP_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.mcp-auth') + // Add a version subdirectory so we don't need to worry about backwards/forwards compatibility yet + return path.join(baseConfigDir, `mcp-remote-${MCP_REMOTE_VERSION}`) +} + +/** + * Ensures the configuration directory exists + */ +export async function ensureConfigDir(): Promise { + try { + const configDir = getConfigDir() + await fs.mkdir(configDir, { recursive: true }) + } catch (error) { + log('Error creating config directory:', error) + throw error + } +} + +/** + * Gets the file path for a config file + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file + * @returns The absolute file path + */ +export function getConfigFilePath(serverUrlHash: string, filename: string): string { + const configDir = getConfigDir() + return path.join(configDir, `${serverUrlHash}_${filename}`) +} + +/** + * Deletes a config file if it exists + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file to delete + */ +export async function deleteConfigFile(serverUrlHash: string, filename: string): Promise { + try { + const filePath = getConfigFilePath(serverUrlHash, filename) + await fs.unlink(filePath) + } catch (error) { + // Ignore if file doesn't exist + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + log(`Error deleting ${filename}:`, error) + } + } +} + +/** + * Reads a JSON file and parses it with the provided schema + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file to read + * @param schema The schema to validate against + * @returns The parsed file content or undefined if the file doesn't exist + */ +export async function readJsonFile(serverUrlHash: string, filename: string, schema: any): Promise { + try { + await ensureConfigDir() + + const filePath = getConfigFilePath(serverUrlHash, filename) + const content = await fs.readFile(filePath, 'utf-8') + const result = await schema.parseAsync(JSON.parse(content)) + // console.log({ filename: result }) + return result + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // console.log(`File ${filename} does not exist`) + return undefined + } + log(`Error reading ${filename}:`, error) + return undefined + } +} + +/** + * Writes a JSON object to a file + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file to write + * @param data The data to write + */ +export async function writeJsonFile(serverUrlHash: string, filename: string, data: any): Promise { + try { + await ensureConfigDir() + const filePath = getConfigFilePath(serverUrlHash, filename) + await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8') + } catch (error) { + log(`Error writing ${filename}:`, error) + throw error + } +} + +/** + * Reads a text file + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file to read + * @param errorMessage Optional custom error message + * @returns The file content as a string + */ +export async function readTextFile(serverUrlHash: string, filename: string, errorMessage?: string): Promise { + try { + await ensureConfigDir() + const filePath = getConfigFilePath(serverUrlHash, filename) + return await fs.readFile(filePath, 'utf-8') + } catch (error) { + throw new Error(errorMessage || `Error reading ${filename}`) + } +} + +/** + * Writes a text string to a file + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file to write + * @param text The text to write + */ +export async function writeTextFile(serverUrlHash: string, filename: string, text: string): Promise { + try { + await ensureConfigDir() + const filePath = getConfigFilePath(serverUrlHash, filename) + await fs.writeFile(filePath, text, 'utf-8') + } catch (error) { + log(`Error writing ${filename}:`, error) + throw error + } +} diff --git a/src/lib/node-oauth-client-provider.ts b/src/lib/node-oauth-client-provider.ts new file mode 100644 index 0000000..0826844 --- /dev/null +++ b/src/lib/node-oauth-client-provider.ts @@ -0,0 +1,123 @@ +import open from 'open' +import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { + OAuthClientInformationFull, + OAuthClientInformationFullSchema, + OAuthTokens, + OAuthTokensSchema, +} from '@modelcontextprotocol/sdk/shared/auth.js' +import type { OAuthProviderOptions } from './types' +import { readJsonFile, writeJsonFile, readTextFile, writeTextFile } from './mcp-auth-config' +import { getServerUrlHash, log, MCP_REMOTE_VERSION } from './utils' + +/** + * Implements the OAuthClientProvider interface for Node.js environments. + * Handles OAuth flow and token storage for MCP clients. + */ +export class NodeOAuthClientProvider implements OAuthClientProvider { + private serverUrlHash: string + private callbackPath: string + private clientName: string + private clientUri: string + private softwareId: string + private softwareVersion: string + + /** + * Creates a new NodeOAuthClientProvider + * @param options Configuration options for the provider + */ + constructor(readonly options: OAuthProviderOptions) { + this.serverUrlHash = getServerUrlHash(options.serverUrl) + this.callbackPath = options.callbackPath || '/oauth/callback' + this.clientName = options.clientName || 'MCP CLI Client' + this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli' + this.softwareId = options.softwareId || '2e6dc280-f3c3-4e01-99a7-8181dbd1d23d' + this.softwareVersion = options.softwareVersion || MCP_REMOTE_VERSION + } + + get redirectUrl(): string { + return `http://localhost:${this.options.callbackPort}${this.callbackPath}` + } + + get clientMetadata() { + return { + redirect_uris: [this.redirectUrl], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + client_name: this.clientName, + client_uri: this.clientUri, + software_id: this.softwareId, + software_version: this.softwareVersion, + } + } + + /** + * Gets the client information if it exists + * @returns The client information or undefined + */ + async clientInformation(): Promise { + // log('Reading client info') + return readJsonFile(this.serverUrlHash, 'client_info.json', OAuthClientInformationFullSchema) + } + + /** + * Saves client information + * @param clientInformation The client information to save + */ + async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise { + // log('Saving client info') + await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation) + } + + /** + * Gets the OAuth tokens if they exist + * @returns The OAuth tokens or undefined + */ + async tokens(): Promise { + // log('Reading tokens') + // console.log(new Error().stack) + return readJsonFile(this.serverUrlHash, 'tokens.json', OAuthTokensSchema) + } + + /** + * Saves OAuth tokens + * @param tokens The tokens to save + */ + async saveTokens(tokens: OAuthTokens): Promise { + // log('Saving tokens') + await writeJsonFile(this.serverUrlHash, 'tokens.json', tokens) + } + + /** + * Redirects the user to the authorization URL + * @param authorizationUrl The URL to redirect to + */ + async redirectToAuthorization(authorizationUrl: URL): Promise { + log(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`) + try { + await open(authorizationUrl.toString()) + log('Browser opened automatically.') + } catch (error) { + log('Could not open browser automatically. Please copy and paste the URL above into your browser.') + } + } + + /** + * Saves the PKCE code verifier + * @param codeVerifier The code verifier to save + */ + async saveCodeVerifier(codeVerifier: string): Promise { + // log('Saving code verifier') + await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier) + } + + /** + * Gets the PKCE code verifier + * @returns The code verifier + */ + async codeVerifier(): Promise { + // log('Reading code verifier') + return await readTextFile(this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session') + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 188fccb..723b93f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -16,6 +16,10 @@ export interface OAuthProviderOptions { clientName?: string /** Client URI to use for OAuth registration */ clientUri?: string + /** Software ID to use for OAuth registration */ + softwareId?: string + /** Software version to use for OAuth registration */ + softwareVersion?: string } /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2b09f57..a0a60dc 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,8 +1,31 @@ import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import { OAuthClientInformationFull, OAuthClientInformationFullSchema } from '@modelcontextprotocol/sdk/shared/auth.js' +import { OAuthCallbackServerOptions } from './types' +import { getConfigFilePath, readJsonFile } from './mcp-auth-config' +import express from 'express' +import net from 'net' +import crypto from 'crypto' +import fs from 'fs/promises' + +// Connection constants +export const REASON_AUTH_NEEDED = 'authentication-needed' +export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport' + +// Transport strategy types +export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first' + +// Package version from package.json +export const MCP_REMOTE_VERSION = require('../../package.json').version const pid = process.pid +export function log(str: string, ...rest: unknown[]) { + // Using stderr so that it doesn't interfere with stdout + console.error(`[${pid}] ${str}`, ...rest) +} /** * Creates a bidirectional proxy between two transports @@ -12,15 +35,22 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo let transportToClientClosed = false let transportToServerClosed = false - transportToClient.onmessage = (message) => { - // @ts-expect-error TODO - console.error('[Local→Remote]', message.method || message.id) + transportToClient.onmessage = (_message) => { + // TODO: fix types + const message = _message as any + log('[Local→Remote]', message.method || message.id) + if (message.method === 'initialize') { + const { clientInfo } = message.params + if (clientInfo) clientInfo.name = `${clientInfo.name} (via mcp-remote ${MCP_REMOTE_VERSION})` + log(JSON.stringify(message, null, 2)) + } transportToServer.send(message).catch(onServerError) } - transportToServer.onmessage = (message) => { - // @ts-expect-error TODO: fix this type - console.error('[Remote→Local]', message.method || message.id) + transportToServer.onmessage = (_message) => { + // TODO: fix types + const message = _message as any + log('[Remote→Local]', message.method || message.id) transportToClient.send(message).catch(onClientError) } @@ -45,57 +75,464 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo transportToServer.onerror = onServerError function onClientError(error: Error) { - console.error('Error from local client:', error) + log('Error from local client:', error) } function onServerError(error: Error) { - console.error('Error from remote server:', error) + log('Error from remote server:', error) } } /** - * Creates and connects to a remote SSE server with OAuth authentication + * Type for the auth initialization function + */ +export type AuthInitializer = () => Promise<{ + waitForAuthCode: () => Promise + skipBrowserAuth: boolean +}> + +/** + * Creates and connects to a remote server with OAuth authentication + * @param client The client to connect with * @param serverUrl The URL of the remote server * @param authProvider The OAuth client provider - * @param waitForAuthCode Function to wait for the auth code - * @returns The connected SSE client transport + * @param headers Additional headers to send with the request + * @param authInitializer Function to initialize authentication when needed + * @param transportStrategy Strategy for selecting transport type ('sse-only', 'http-only', 'sse-first', 'http-first') + * @param recursionReasons Set of reasons for recursive calls (internal use) + * @returns The connected transport */ export async function connectToRemoteServer( + client: Client | null, serverUrl: string, authProvider: OAuthClientProvider, - waitForAuthCode: () => Promise, -): Promise { - console.error(`[${pid}] Connecting to remote server: ${serverUrl}`) + headers: Record, + authInitializer: AuthInitializer, + transportStrategy: TransportStrategy = 'http-first', + recursionReasons: Set = new Set(), +): Promise { + log(`[${pid}] Connecting to remote server: ${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 | undefined), + ...headers, + ...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}), + Accept: 'text/event-stream', + } as Record, + }), + ) + }, + } + + log(`Using transport strategy: ${transportStrategy}`) + // Determine if we should attempt to fallback on error + // Choose transport based on user strategy and recursion history + const shouldAttemptFallback = transportStrategy === 'http-first' || transportStrategy === 'sse-first' + + // Create transport instance based on the strategy + const sseTransport = transportStrategy === 'sse-only' || transportStrategy === 'sse-first' + const transport = sseTransport + ? new SSEClientTransport(url, { + authProvider, + requestInit: { headers }, + eventSourceInit, + }) + : new StreamableHTTPClientTransport(url, { + authProvider, + requestInit: { headers }, + }) try { - await transport.start() - console.error('Connected to remote server') + if (client) { + await client.connect(transport) + } else { + await transport.start() + if (!sseTransport) { + // Extremely hacky, but we didn't actually send a request when calling transport.start() above, so we don't + // know if we're even talking to an HTTP server. But if we forced that now we'd get an error later saying that + // the client is already connected. So let's just create a one-off client to make a single request and figure + // out if we're actually talking to an HTTP server or not. + const testTransport = new StreamableHTTPClientTransport(url, { authProvider, requestInit: { headers } }) + const testClient = new Client({ name: 'mcp-remote-fallback-test', version: '0.0.0' }, { capabilities: {} }) + await testClient.connect(testTransport) + } + } + log(`Connected to remote server using ${transport.constructor.name}`) + return transport } catch (error) { - if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { - console.error('Authentication required. Waiting for authorization...') + // 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...') + } // Wait for the authorization code from the callback const code = await waitForAuthCode() try { - console.error('Completing authorization...') + log('Completing authorization...') await transport.finishAuth(code) - // Create a new transport after auth - const newTransport = new SSEClientTransport(url, { authProvider }) - await newTransport.start() - console.error('Connected to remote server after authentication') - return newTransport + if (recursionReasons.has(REASON_AUTH_NEEDED)) { + const errorMessage = `Already attempted reconnection for reason: ${REASON_AUTH_NEEDED}. Giving up.` + log(errorMessage) + throw new Error(errorMessage) + } + + // Track this reason for recursion + recursionReasons.add(REASON_AUTH_NEEDED) + log(`Recursively reconnecting for reason: ${REASON_AUTH_NEEDED}`) + + // Recursively call connectToRemoteServer with the updated recursion tracking + return connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy, recursionReasons) } catch (authError) { - console.error('Authorization error:', authError) + log('Authorization error:', authError) throw authError } } else { - console.error('Connection error:', error) + log('Connection error:', error) throw error } } } + +/** + * 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 setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServerOptions) { + let authCode: string | null = null + const app = express() + + // Create a promise to track when auth is completed + let authCompletedResolve: (code: string) => void + const authCompletedPromise = new Promise((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) => { + const code = req.query.code as string | undefined + if (!code) { + res.status(400).send('Error: No authorization code received') + return + } + + authCode = code + log('Auth code received, resolving promise') + authCompletedResolve(code) + + res.send(` + Authorization successful! + You may close this window and return to the CLI. + + `) + + // Notify main flow that auth code is available + options.events.emit('auth-code-received', code) + }) + + const server = app.listen(options.port, () => { + log(`OAuth callback server running at http://127.0.0.1:${options.port}`) + }) + + const waitForAuthCode = (): Promise => { + return new Promise((resolve) => { + if (authCode) { + resolve(authCode) + return + } + + options.events.once('auth-code-received', (code) => { + resolve(code) + }) + }) + } + + 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 } +} + +async function findExistingClientPort(serverUrlHash: string): Promise { + const clientInfo = await readJsonFile(serverUrlHash, 'client_info.json', OAuthClientInformationFullSchema) + if (!clientInfo) { + return undefined + } + + const localhostRedirectUri = clientInfo.redirect_uris.map((uri) => new URL(uri)).find(({ hostname }) => hostname === 'localhost') + if (!localhostRedirectUri) { + throw new Error('Cannot find localhost callback URI from existing client information') + } + + return parseInt(localhostRedirectUri.port) +} + +function calculateDefaultPort(serverUrlHash: string): number { + // Convert the first 4 bytes of the serverUrlHash into a port offset + const offset = parseInt(serverUrlHash.substring(0, 4), 16) + // Pick a consistent but random-seeming port from 3335 to 49151 + return 3335 + (offset % 45816) +} + +/** + * Finds an available port on the local machine + * @param preferredPort Optional preferred port to try first + * @returns A promise that resolves to an available port number + */ +export async function findAvailablePort(preferredPort?: number): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + // If preferred port is in use, get a random port + server.listen(0) + } else { + reject(err) + } + }) + + server.on('listening', () => { + const { port } = server.address() as net.AddressInfo + server.close(() => { + resolve(port) + }) + }) + + // Try preferred port first, or get a random port + server.listen(preferredPort || 0) + }) +} + +/** + * Parses command line arguments for MCP clients and proxies + * @param args Command line arguments + * @param usage Usage message to show on error + * @returns A promise that resolves to an object with parsed serverUrl, callbackPort and headers + */ +export async function parseCommandLineArgs(args: string[], usage: string) { + // Process headers + const headers: Record = {} + let i = 0 + while (i < args.length) { + if (args[i] === '--header' && i < args.length - 1) { + const value = args[i + 1] + const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/) + if (match) { + 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 specifiedPort = args[1] ? parseInt(args[1]) : undefined + const allowHttp = args.includes('--allow-http') + + // Parse transport strategy + let transportStrategy: TransportStrategy = 'http-first' // Default + const transportIndex = args.indexOf('--transport') + if (transportIndex !== -1 && transportIndex < args.length - 1) { + const strategy = args[transportIndex + 1] + if (strategy === 'sse-only' || strategy === 'http-only' || strategy === 'sse-first' || strategy === 'http-first') { + transportStrategy = strategy as TransportStrategy + log(`Using transport strategy: ${transportStrategy}`) + } else { + log(`Warning: Ignoring invalid transport strategy: ${strategy}. Valid values are: sse-only, http-only, sse-first, http-first`) + } + } + + if (!serverUrl) { + log(usage) + process.exit(1) + } + + const url = new URL(serverUrl) + const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:' + + if (!(url.protocol == 'https:' || isLocalhost || allowHttp)) { + log('Error: Non-HTTPS URLs are only allowed for localhost or when --allow-http flag is provided') + log(usage) + process.exit(1) + } + const serverUrlHash = getServerUrlHash(serverUrl) + const defaultPort = calculateDefaultPort(serverUrlHash) + + // Use the specified port, or the existing client port or fallback to find an available one + const [existingClientPort, availablePort] = await Promise.all([findExistingClientPort(serverUrlHash), findAvailablePort(defaultPort)]) + let callbackPort: number + + if (specifiedPort) { + if (existingClientPort && specifiedPort !== existingClientPort) { + log( + `Warning! Specified callback port of ${specifiedPort}, which conflicts with existing client registration port ${existingClientPort}. Deleting existing client data to force reregistration.`, + ) + await fs.rm(getConfigFilePath(serverUrlHash, 'client_info.json')) + } + log(`Using specified callback port: ${specifiedPort}`) + callbackPort = specifiedPort + } else if (existingClientPort) { + log(`Using existing client port: ${existingClientPort}`) + callbackPort = existingClientPort + } else { + log(`Using automatically selected callback port: ${availablePort}`) + callbackPort = availablePort + } + + if (Object.keys(headers).length > 0) { + 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, headers, transportStrategy } +} + +/** + * Sets up signal handlers for graceful shutdown + * @param cleanup Cleanup function to run on shutdown + */ +export function setupSignalHandlers(cleanup: () => Promise) { + process.on('SIGINT', async () => { + log('\nShutting down...') + await cleanup() + process.exit(0) + }) + + // Keep the process alive + process.stdin.resume() + process.stdin.on('end', async () => { + log('\nShutting down...') + await cleanup() + process.exit(0) + }) +} + +/** + * 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') +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..535bfe2 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +/** + * MCP Proxy with OAuth support + * A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication. + * + * Run with: npx tsx proxy.ts https://example.remote/server [callback-port] + * + * If callback-port is not specified, an available port will be automatically selected. + */ + +import { EventEmitter } from 'events' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + connectToRemoteServer, + log, + mcpProxy, + parseCommandLineArgs, + setupSignalHandlers, + getServerUrlHash, + MCP_REMOTE_VERSION, + TransportStrategy, +} from './lib/utils' +import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' +import { createLazyAuthCoordinator } from './lib/coordination' + +/** + * Main function to run the proxy + */ +async function runProxy( + serverUrl: string, + callbackPort: number, + headers: Record, + transportStrategy: TransportStrategy = 'http-first', +) { + // Set up event emitter for auth flow + const events = new EventEmitter() + + // Get the server URL hash for lockfile operations + const serverUrlHash = getServerUrlHash(serverUrl) + + // Create a lazy auth coordinator + const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events) + + // Create the OAuth client provider + const authProvider = new NodeOAuthClientProvider({ + serverUrl, + callbackPort, + clientName: 'MCP CLI Proxy', + }) + + // Create the STDIO transport for local connections + const localTransport = new StdioServerTransport() + + // Keep track of the server instance for cleanup + let server: any = null + + // Define an auth initializer function + const authInitializer = async () => { + const authState = await authCoordinator.initializeAuth() + + // Store server in outer scope for cleanup + server = authState.server + + // If auth was completed by another instance, just log that we'll use the auth from disk + if (authState.skipBrowserAuth) { + log('Authentication was completed by another instance - will use tokens from disk') + // TODO: remove, the callback is happening before the tokens are exchanged + // so we're slightly too early + await new Promise((res) => setTimeout(res, 1_000)) + } + + return { + waitForAuthCode: authState.waitForAuthCode, + skipBrowserAuth: authState.skipBrowserAuth, + } + } + + try { + // Connect to remote server with lazy authentication + const remoteTransport = await connectToRemoteServer(null, serverUrl, authProvider, headers, authInitializer, transportStrategy) + + // Set up bidirectional proxy between local and remote transports + mcpProxy({ + transportToClient: localTransport, + transportToServer: remoteTransport, + }) + + // Start the local STDIO server + await localTransport.start() + log('Local STDIO server running') + log(`Proxy established successfully between local STDIO and remote ${remoteTransport.constructor.name}`) + log('Press Ctrl+C to exit') + + // Setup cleanup handler + const cleanup = async () => { + await remoteTransport.close() + await localTransport.close() + // Only close the server if it was initialized + if (server) { + server.close() + } + } + setupSignalHandlers(cleanup) + } catch (error) { + log('Fatal error:', error) + if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) { + log(`You may be behind a VPN! + +If you are behind a VPN, you can try setting the NODE_EXTRA_CA_CERTS environment variable to point +to the CA certificate file. If using claude_desktop_config.json, this might look like: + +{ + "mcpServers": { + "\${mcpServerName}": { + "command": "npx", + "args": [ + "mcp-remote", + "https://remote.mcp.server/sse" + ], + "env": { + "NODE_EXTRA_CA_CERTS": "\${your CA certificate file path}.pem" + } + } + } +} + `) + } + // Only close the server if it was initialized + if (server) { + server.close() + } + process.exit(1) + } +} + +// Parse command-line arguments and run the proxy +parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts [callback-port]') + .then(({ serverUrl, callbackPort, headers, transportStrategy }) => { + return runProxy(serverUrl, callbackPort, headers, transportStrategy) + }) + .catch((error) => { + log('Fatal error:', error) + process.exit(1) + }) diff --git a/src/react/index.ts b/src/react/index.ts deleted file mode 100644 index 43f8a4f..0000000 --- a/src/react/index.ts +++ /dev/null @@ -1,1243 +0,0 @@ -import { CallToolResultSchema, JSONRPCMessage, ListToolsResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js' -import { useCallback, useEffect, useRef, useState } from 'react' -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { - OAuthClientProvider, - discoverOAuthMetadata, - exchangeAuthorization, - startAuthorization, -} from '@modelcontextprotocol/sdk/client/auth.js' -import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' - -function assert(condition: unknown, message: string): asserts condition { - if (!condition) { - throw new Error(message) - } -} - -export type UseMcpOptions = { - /** The /sse URL of your remote MCP server */ - url: string - /** OAuth client name for registration */ - clientName?: string - /** OAuth client URI for registration */ - clientUri?: string - /** Custom callback URL for OAuth redirect (defaults to /oauth/callback on the current origin) */ - callbackUrl?: string - /** Storage key prefix for OAuth data (defaults to "mcp_auth") */ - storageKeyPrefix?: string - /** Custom configuration for the MCP client */ - clientConfig?: { - name?: string - version?: string - } - /** Whether to enable debug logging */ - debug?: boolean - /** Auto retry connection if it fails, with delay in ms (default: false) */ - autoRetry?: boolean | number - /** Auto reconnect if connection is lost, with delay in ms (default: 3000) */ - autoReconnect?: boolean | number - /** Popup window features (dimensions and behavior) for OAuth */ - popupFeatures?: string -} - -export type UseMcpResult = { - tools: Tool[] - /** - * The current state of the MCP connection. This will be one of: - * - 'discovering': Finding out whether there is in fact a server at that URL, and what its capabilities are - * - 'authenticating': The server has indicated we must authenticate, so we can't proceed until that's complete - * - 'connecting': The connection to the MCP server is being established. This happens before we know whether we need to authenticate or not, and then again once we have credentials - * - 'loading': We're connected to the MCP server, and now we're loading its resources/prompts/tools - * - 'ready': The MCP server is connected and ready to be used - * - 'failed': The connection to the MCP server failed - * */ - state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed' - /** If the state is 'failed', this will be the error message */ - error?: string - /** - * If authorization was blocked, this will contain the URL to authorize manually - * The app can render this as a link with target="_blank" so the user can complete - * authorization without leaving the app - */ - authUrl?: string - /** All internal log messages */ - log: { level: 'debug' | 'info' | 'warn' | 'error'; message: string }[] - /** Call a tool on the MCP server */ - callTool: (name: string, args?: Record) => Promise - /** Manually retry connection if it's in a failed state */ - retry: () => void - /** Manually disconnect from the MCP server */ - disconnect: () => void - /** - * Manually trigger authentication - * @returns Auth URL that can be used to manually open a new window - */ - authenticate: () => Promise - /** - * Clear all localStorage items for this server - */ - clearStorage: () => void -} - -type StoredState = { - authorizationUrl: string - metadata: OAuthMetadata - serverUrlHash: string - expiry: number -} - -/** - * Browser-compatible OAuth client provider for MCP - */ -class BrowserOAuthClientProvider implements OAuthClientProvider { - private storageKeyPrefix: string - serverUrlHash: string - private clientName: string - private clientUri: string - private callbackUrl: string - // Store additional options for popup windows - private popupFeatures: string - - constructor( - readonly serverUrl: string, - options: { - storageKeyPrefix?: string - clientName?: string - clientUri?: string - callbackUrl?: string - popupFeatures?: string - } = {}, - ) { - this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth' - this.serverUrlHash = this.hashString(serverUrl) - this.clientName = options.clientName || 'MCP Browser Client' - this.clientUri = options.clientUri || window.location.origin - this.callbackUrl = options.callbackUrl || new URL('/oauth/callback', window.location.origin).toString() - this.popupFeatures = options.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes' - } - - get redirectUrl(): string { - return this.callbackUrl - } - - get clientMetadata() { - return { - redirect_uris: [this.redirectUrl], - token_endpoint_auth_method: 'none', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - client_name: this.clientName, - client_uri: this.clientUri, - } - } - - /** - * Clears all storage items related to this server - * @returns The number of items cleared - */ - clearStorage(): number { - const prefix = `${this.storageKeyPrefix}_${this.serverUrlHash}` - const keysToRemove = [] - - // Find all keys that match the prefix - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i) - if (key && key.startsWith(prefix)) { - keysToRemove.push(key) - } - } - - // Also check for any state keys - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i) - if (key && key.startsWith(`${this.storageKeyPrefix}:state_`)) { - // Load state to check if it's for this server - try { - const state = JSON.parse(localStorage.getItem(key) || '{}') - if (state.serverUrlHash === this.serverUrlHash) { - keysToRemove.push(key) - } - } catch (e) { - // Ignore JSON parse errors - } - } - } - - // Remove all matching keys - keysToRemove.forEach((key) => localStorage.removeItem(key)) - - return keysToRemove.length - } - - private hashString(str: string): string { - // Simple hash function for browser environments - let hash = 0 - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i) - hash = (hash << 5) - hash + char - hash = hash & hash // Convert to 32bit integer - } - return Math.abs(hash).toString(16) - } - - getKey(key: string): string { - return `${this.storageKeyPrefix}_${this.serverUrlHash}_${key}` - } - - async clientInformation(): Promise { - const key = this.getKey('client_info') - const data = localStorage.getItem(key) - if (!data) return undefined - - try { - return JSON.parse(data) as OAuthClientInformation - } catch (e) { - return undefined - } - } - - async saveClientInformation(clientInformation: OAuthClientInformation): Promise { - const key = this.getKey('client_info') - localStorage.setItem(key, JSON.stringify(clientInformation)) - } - - async tokens(): Promise { - const key = this.getKey('tokens') - const data = localStorage.getItem(key) - if (!data) return undefined - - try { - return JSON.parse(data) as OAuthTokens - } catch (e) { - return undefined - } - } - - async saveTokens(tokens: OAuthTokens): Promise { - const key = this.getKey('tokens') - localStorage.setItem(key, JSON.stringify(tokens)) - } - - /** - * Redirect method that matches the interface expected by OAuthClientProvider - */ - async redirectToAuthorization(authorizationUrl: URL): Promise { - // Simply open the URL in the current window - console.log('WE WERE ABOUT TO REDIRECT BUT WE DONT DO THAT HERE') - // window.location.href = authorizationUrl.toString() - } - - /** - * Extended popup-based authorization method specific to browser environments - */ - async openAuthorizationPopup( - authorizationUrl: URL, - metadata: OAuthMetadata, - ): Promise<{ success: boolean; popupBlocked?: boolean; url: string }> { - // Use existing state parameter if it exists in the URL - const existingState = authorizationUrl.searchParams.get('state') - - if (!existingState) { - // This should not happen as startAuthFlow should've added state - // But if it doesn't exist, add it as a fallback - const state = Math.random().toString(36).substring(2) - const stateKey = `${this.storageKeyPrefix}:state_${state}` - - localStorage.setItem( - stateKey, - JSON.stringify({ - authorizationUrl: authorizationUrl.toString(), - metadata, - serverUrlHash: this.serverUrlHash, - expiry: +new Date() + 1000 * 60 * 5 /* 5 minutes */, - } as StoredState), - ) - - authorizationUrl.searchParams.set('state', state) - } - - const authUrl = authorizationUrl.toString() - - // Store the auth URL in case we need it for manual authentication - localStorage.setItem(this.getKey('auth_url'), authUrl) - - try { - // Open the authorization URL in a popup window - const popup = window.open(authUrl, 'mcp_auth', this.popupFeatures) - - // Check if popup was blocked or closed immediately - if (!popup || popup.closed || popup.closed === undefined) { - console.warn('Popup blocked. Returning error.') - return { success: false, popupBlocked: true, url: authUrl } - } - - // Try to access the popup to confirm it's not blocked - try { - // Just accessing any property will throw if popup is blocked - const popupLocation = popup.location - // If we can read location.href, the popup is definitely working - if (popupLocation.href) { - // Successfully opened popup - return { success: true, url: authUrl } - } - } catch (e) { - // Access to the popup was denied, indicating it's blocked - console.warn('Popup blocked (security exception).') - return { success: false, popupBlocked: true, url: authUrl } - } - - // If we got here, popup is working - return { success: true, url: authUrl } - } catch (e) { - // Error opening popup - console.warn('Error opening popup:', e) - return { success: false, popupBlocked: true, url: authUrl } - } - } - - async saveCodeVerifier(codeVerifier: string): Promise { - const key = this.getKey('code_verifier') - localStorage.setItem(key, codeVerifier) - } - - async codeVerifier(): Promise { - const key = this.getKey('code_verifier') - const verifier = localStorage.getItem(key) - if (!verifier) { - throw new Error('No code verifier found in storage') - } - return verifier - } -} - -/** - * Class to encapsulate all MCP client functionality, - * including authentication flow and connection management - */ -class McpClient { - // State - private _state: UseMcpResult['state'] = 'discovering' - private _error?: string - private _tools: Tool[] = [] - private _log: UseMcpResult['log'] = [] - private _authUrl?: string - - // Client and transport - private client: Client | null = null - private transport: SSEClientTransport | null = null - private authProvider: BrowserOAuthClientProvider | undefined = undefined - - // Authentication state - private metadata?: OAuthMetadata - private authUrlRef?: URL - private authState?: string - private codeVerifier?: string - private connecting = false - - // Update callbacks - private onStateChange: (state: UseMcpResult['state']) => void - private onToolsChange: (tools: Tool[]) => void - private onErrorChange: (error?: string) => void - private onLogChange: (log: UseMcpResult['log']) => void - private onAuthUrlChange: (authUrl?: string) => void - - constructor( - private url: string, - private options: { - clientName?: string - clientUri?: string - callbackUrl?: string - storageKeyPrefix?: string - clientConfig?: { - name?: string - version?: string - } - debug?: boolean - autoRetry?: boolean | number - autoReconnect?: boolean | number - popupFeatures?: string - }, - callbacks: { - onStateChange: (state: UseMcpResult['state']) => void - onToolsChange: (tools: Tool[]) => void - onErrorChange: (error?: string) => void - onLogChange: (log: UseMcpResult['log']) => void - onAuthUrlChange: (authUrl?: string) => void - }, - ) { - // Initialize callbacks - this.onStateChange = callbacks.onStateChange - this.onToolsChange = callbacks.onToolsChange - this.onErrorChange = callbacks.onErrorChange - this.onLogChange = callbacks.onLogChange - this.onAuthUrlChange = callbacks.onAuthUrlChange - - // Initialize auth provider - this.initAuthProvider() - } - - get state(): UseMcpResult['state'] { - return this._state - } - - get tools(): Tool[] { - return this._tools - } - - get error(): string | undefined { - return this._error - } - - get log(): UseMcpResult['log'] { - return this._log - } - - get authUrl(): string | undefined { - return this._authUrl - } - - /** - * Initialize the auth provider - */ - private initAuthProvider(): void { - if (!this.authProvider) { - this.authProvider = new BrowserOAuthClientProvider(this.url, { - storageKeyPrefix: this.options.storageKeyPrefix, - clientName: this.options.clientName, - clientUri: this.options.clientUri, - callbackUrl: this.options.callbackUrl, - }) - } - } - - /** - * Add a log entry - */ - private addLog(level: 'debug' | 'info' | 'warn' | 'error', message: string): void { - if (level === 'debug' && !this.options.debug) return - this._log = [...this._log, { level, message }] - this.onLogChange(this._log) - } - - /** - * Update the state - */ - private setState(state: UseMcpResult['state']): void { - this._state = state - this.onStateChange(state) - } - - /** - * Update the error - */ - private setError(error?: string): void { - this._error = error - this.onErrorChange(error) - } - - /** - * Update the tools - */ - private setTools(tools: Tool[]): void { - this._tools = tools - this.onToolsChange(tools) - } - - /** - * Update the auth URL - */ - private setAuthUrl(authUrl?: string): void { - this._authUrl = authUrl - this.onAuthUrlChange(authUrl) - } - - /** - * Handle OAuth discovery and authentication - */ - private async discoverOAuthAndAuthenticate(error: Error): Promise { - try { - // Discover OAuth metadata now that we know we need it - if (!this.metadata) { - this.addLog('info', 'Discovering OAuth metadata...') - this.metadata = await discoverOAuthMetadata(this.url) - this.addLog('debug', `OAuth metadata: ${this.metadata ? 'Found' : 'Not available'}`) - } - - // If metadata is found, start auth flow - if (this.metadata) { - this.setState('authenticating') - - try { - // Start authentication process - await this.handleAuthentication() - - // After successful auth, retry connection - // Important: We need to fully disconnect and reconnect - await this.disconnect() - await this.connect() - } catch (authErr) { - this.addLog('error', `Authentication error: ${authErr instanceof Error ? authErr.message : String(authErr)}`) - this.setState('failed') - this.setError(`Authentication failed: ${authErr instanceof Error ? authErr.message : String(authErr)}`) - this.connecting = false - } - } else { - // No OAuth metadata available - this.setState('failed') - this.setError(`Authentication required but no OAuth metadata found: ${error.message}`) - this.connecting = false - } - } catch (oauthErr) { - this.addLog('error', `OAuth discovery error: ${oauthErr instanceof Error ? oauthErr.message : String(oauthErr)}`) - this.setState('failed') - this.setError(`Authentication setup failed: ${oauthErr instanceof Error ? oauthErr.message : String(oauthErr)}`) - this.connecting = false - } - } - - /** - * Connect to the MCP server - */ - async connect(): Promise { - // Prevent multiple simultaneous connection attempts - if (this.connecting) return - this.connecting = true - - try { - this.setState('discovering') - this.setError(undefined) - - // Create MCP client - this.client = new Client( - { - name: this.options.clientConfig?.name || 'mcp-react-client', - version: this.options.clientConfig?.version || '0.1.0', - }, - { - capabilities: { - sampling: {}, - }, - }, - ) - - // Create SSE transport - this.setState('connecting') - this.addLog('info', 'Creating transport...') - - const serverUrl = new URL(this.url) - this.transport = new SSEClientTransport(serverUrl, { - authProvider: this.authProvider, - }) - - // Set up transport handlers - this.transport.onmessage = (message: JSONRPCMessage) => { - // @ts-expect-error TODO: fix this type - this.addLog('debug', `Received message: ${message.method || message.id}`) - } - - this.transport.onerror = (err: Error) => { - this.addLog('error', `Transport error: ${err.message}`) - - if (err.message.includes('Unauthorized')) { - // Only discover OAuth metadata and authenticate if we get a 401 - this.discoverOAuthAndAuthenticate(err) - } else { - this.setState('failed') - this.setError(`Connection error: ${err.message}`) - this.connecting = false - } - } - - this.transport.onclose = () => { - this.addLog('info', 'Connection closed') - // If we were previously connected, try to reconnect - if (this.state === 'ready' && this.options.autoReconnect) { - const delay = typeof this.options.autoReconnect === 'number' ? this.options.autoReconnect : 3000 - this.addLog('info', `Will reconnect in ${delay}ms...`) - setTimeout(() => { - this.disconnect().then(() => this.connect()) - }, delay) - } - } - - // Try connecting transport - try { - this.addLog('info', 'Starting transport...') - // await this.transport.start() - } catch (err) { - this.addLog('error', `Transport start error: ${err instanceof Error ? err.message : String(err)}`) - - if (err instanceof Error && err.message.includes('Unauthorized')) { - // Only discover OAuth and authenticate if we get a 401 - await this.discoverOAuthAndAuthenticate(err) - return // Important: Return here to avoid proceeding with the unauthorized connection - } else { - this.setState('failed') - this.setError(`Connection error: ${err instanceof Error ? err.message : String(err)}`) - this.connecting = false - return - } - } - - // Connect client - try { - this.addLog('info', 'Connecting client...') - this.setState('loading') - await this.client.connect(this.transport) - this.addLog('info', 'Client connected') - - // Load tools - try { - this.addLog('info', 'Loading tools...') - const toolsResponse = await this.client.request({ method: 'tools/list' }, ListToolsResultSchema) - this.setTools(toolsResponse.tools) - this.addLog('info', `Loaded ${toolsResponse.tools.length} tools`) - - // Connection completed successfully - this.setState('ready') - this.connecting = false - } catch (toolErr) { - this.addLog('error', `Error loading tools: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`) - // We're still connected, just couldn't load tools - this.setState('ready') - this.connecting = false - } - } catch (connectErr) { - this.addLog('error', `Client connect error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`) - - if (connectErr instanceof Error && connectErr.message.includes('Unauthorized')) { - // Only discover OAuth and authenticate if we get a 401 - await this.discoverOAuthAndAuthenticate(connectErr) - } else { - this.setState('failed') - this.setError(`Connection error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`) - this.connecting = false - } - } - } catch (err) { - this.addLog('error', `Unexpected error: ${err instanceof Error ? err.message : String(err)}`) - this.setState('failed') - this.setError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`) - this.connecting = false - } - } - - /** - * Disconnect from the MCP server - */ - async disconnect(): Promise { - if (this.client) { - try { - await this.client.close() - } catch (err) { - this.addLog('error', `Error closing client: ${err instanceof Error ? err.message : String(err)}`) - } - this.client = null - } - - if (this.transport) { - try { - await this.transport.close() - } catch (err) { - this.addLog('error', `Error closing transport: ${err instanceof Error ? err.message : String(err)}`) - } - this.transport = null - } - - this.connecting = false - this.setState('discovering') - this.setTools([]) - this.setError(undefined) - } - - /** - * Start the auth flow and get the auth URL - */ - async startAuthFlow(): Promise { - if (!this.authProvider || !this.metadata) { - throw new Error('Auth provider or metadata not available') - } - - this.addLog('info', 'Starting authentication flow...') - - // Check if we have client info - let clientInfo = await this.authProvider.clientInformation() - - if (!clientInfo) { - // Register client dynamically - this.addLog('info', 'No client information found, registering...') - // Note: In a complete implementation, you'd register the client here - // This would be done server-side in a real application - throw new Error('Dynamic client registration not implemented in this example') - } - - // Start authorization flow - this.addLog('info', 'Preparing authorization...') - const { authorizationUrl, codeVerifier } = await startAuthorization(this.url, { - metadata: this.metadata, - clientInformation: clientInfo, - redirectUrl: this.authProvider.redirectUrl, - }) - - // Save code verifier and auth URL for later use - await this.authProvider.saveCodeVerifier(codeVerifier) - this.codeVerifier = codeVerifier - - // Generate state parameter that will be used for both popup and manual flows - const state = Math.random().toString(36).substring(2) - const stateKey = `${this.options.storageKeyPrefix}:state_${state}` - - // Store state for later retrieval - localStorage.setItem( - stateKey, - JSON.stringify({ - authorizationUrl: authorizationUrl.toString(), - metadata: this.metadata, - serverUrlHash: this.authProvider.serverUrlHash, - expiry: +new Date() + 1000 * 60 * 5 /* 5 minutes */, - } as StoredState), - ) - - // Add state to the URL - authorizationUrl.searchParams.set('state', state) - - // Store the state and URL for later use - this.authState = state - this.authUrlRef = authorizationUrl - - // Set manual auth URL (already includes state parameter) - this.setAuthUrl(authorizationUrl.toString()) - - return authorizationUrl - } - - /** - * Handle authentication flow - */ - async handleAuthentication(): Promise { - if (!this.authProvider) { - throw new Error('Auth provider not available') - } - - // Get or create the auth URL - if (!this.authUrlRef) { - try { - await this.startAuthFlow() - } catch (err) { - this.addLog('error', `Failed to start auth flow: ${err instanceof Error ? err.message : String(err)}`) - throw err - } - } - - if (!this.authUrlRef) { - throw new Error('Failed to create authorization URL') - } - - // Set up listener for post-auth message - const authPromise = new Promise((resolve, reject) => { - let pollIntervalId: number | undefined - - const timeoutId = setTimeout( - () => { - window.removeEventListener('message', messageHandler) - if (pollIntervalId) clearTimeout(pollIntervalId) - reject(new Error('Authentication timeout after 5 minutes')) - }, - 5 * 60 * 1000, - ) - - const messageHandler = (event: MessageEvent) => { - // Verify origin for security - if (event.origin !== window.location.origin) return - - if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { - window.removeEventListener('message', messageHandler) - clearTimeout(timeoutId) - if (pollIntervalId) clearTimeout(pollIntervalId) - - resolve(event.data.code) - } - } - - window.addEventListener('message', messageHandler) - - // Add polling fallback to check for tokens in localStorage - const pollForTokens = () => { - try { - // Check if tokens have appeared in localStorage - const tokensKey = this.authProvider!.getKey('tokens') - const storedTokens = localStorage.getItem(tokensKey) - - if (storedTokens) { - // Tokens found, clean up and resolve - window.removeEventListener('message', messageHandler) - clearTimeout(timeoutId) - if (pollIntervalId) clearTimeout(pollIntervalId) - - // Parse tokens to make sure they're valid - const tokens = JSON.parse(storedTokens) - if (tokens.access_token) { - console.log('Found tokens in localStorage via polling') - // Resolve with an object that indicates tokens are already available - // This will signal to handleAuthCompletion that no token exchange is needed - resolve('TOKENS_ALREADY_EXCHANGED') - } - } - } catch (err) { - // Error during polling, continue anyway - console.error(err) - } - } - - // Start polling every 500ms using setTimeout for recursive polling - const poll = () => { - pollIntervalId = setTimeout(poll, 500) as unknown as number - pollForTokens() - } - - poll() // Start the polling - }) - - // Redirect to authorization - this.addLog('info', 'Opening authorization window...') - assert(this.metadata, 'Metadata not available') - const redirectResult = await this.authProvider.openAuthorizationPopup(this.authUrlRef, this.metadata) - - if (!redirectResult.success) { - // Popup was blocked - this.setState('failed') - this.setError('Authentication popup was blocked by the browser. Please click the link to authenticate in a new window.') - this.setAuthUrl(redirectResult.url) - this.addLog('warn', 'Authentication popup was blocked. User needs to manually authorize.') - throw new Error('Authentication popup blocked') - } - - // Wait for auth to complete - this.addLog('info', 'Waiting for authorization...') - const code = await authPromise - this.addLog('info', 'Authorization code received') - - return code - } - - /** - * Handle authentication completion - * @param code - The authorization code or special token indicator - */ - async handleAuthCompletion(code: string): Promise { - if (!this.authProvider || !this.transport) { - throw new Error('Authentication context not available') - } - - try { - // Check if this is our special token indicator - if (code === 'TOKENS_ALREADY_EXCHANGED') { - this.addLog('info', 'Using already exchanged tokens from localStorage') - // No need to exchange tokens, they're already in localStorage - } else { - // We received an authorization code that needs to be exchanged - this.addLog('info', 'Finishing authorization with code exchange...') - await this.transport.finishAuth(code) - this.addLog('info', 'Authorization code exchanged for tokens') - } - - this.addLog('info', 'Authorization completed') - - // Reset auth URL state - this.authUrlRef = undefined - this.setAuthUrl(undefined) - - // Reconnect with the new auth token - important to do a full disconnect/connect cycle - await this.disconnect() - await this.connect() - } catch (err) { - this.addLog('error', `Auth completion error: ${err instanceof Error ? err.message : String(err)}`) - this.setState('failed') - this.setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`) - } - } - - /** - * Call a tool on the MCP server - */ - async callTool(name: string, args?: Record): Promise { - if (!this.client || this.state !== 'ready') { - throw new Error('MCP client not ready') - } - - try { - const result = await this.client.request( - { - method: 'tools/call', - params: { name, arguments: args }, - }, - CallToolResultSchema, - ) - return result - } catch (err) { - this.addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`) - throw err - } - } - - /** - * Retry connection - */ - retry(): void { - if (this.state === 'failed') { - this.disconnect().then(() => this.connect()) - } - } - - /** - * Manually trigger authentication - */ - async authenticate(): Promise { - if (!this.authProvider) { - try { - // Discover OAuth metadata if we don't have it yet - this.addLog('info', 'Discovering OAuth metadata...') - this.metadata = await discoverOAuthMetadata(this.url) - this.addLog('debug', `OAuth metadata: ${this.metadata ? 'Found' : 'Not available'}`) - - if (!this.metadata) { - throw new Error('No OAuth metadata available') - } - - // Initialize the auth provider now that we have metadata - this.initAuthProvider() - } catch (err) { - this.addLog('error', `Failed to discover OAuth metadata: ${err instanceof Error ? err.message : String(err)}`) - return undefined - } - } - - try { - // If we don't have an auth URL yet with state param, start a new flow - if (!this.authUrlRef || !this.authUrlRef.searchParams.get('state')) { - await this.startAuthFlow() - } - - if (!this.authUrlRef) { - throw new Error('Failed to create authorization URL') - } - - // The URL already has the state parameter from startAuthFlow - return this.authUrlRef.toString() - } catch (err) { - this.addLog('error', `Error preparing manual authentication: ${err instanceof Error ? err.message : String(err)}`) - return undefined - } - } - - /** - * Clear all localStorage items for this server - */ - clearStorage(): number { - if (!this.authProvider) { - this.addLog('warn', 'Cannot clear storage: auth provider not initialized') - return 0 - } - - // Use the provider's method to clear storage - const clearedCount = this.authProvider.clearStorage() - - // Clear auth-related state in the class - this.authUrlRef = undefined - this.setAuthUrl(undefined) - this.metadata = undefined - this.codeVerifier = undefined - - this.addLog('info', `Cleared ${clearedCount} storage items for server`) - - return clearedCount - } -} - -/** - * useMcp is a React hook that connects to a remote MCP server, negotiates auth - * (including opening a popup window or new tab to complete the OAuth flow), - * and enables passing a list of tools (once loaded) to ai-sdk (using `useChat`). - */ -export function useMcp(options: UseMcpOptions): UseMcpResult { - const [state, setState] = useState('discovering') - const [tools, setTools] = useState([]) - const [error, setError] = useState(undefined) - const [log, setLog] = useState([]) - const [authUrl, setAuthUrl] = useState(undefined) - - // Use a ref to maintain a single instance of the McpClient - const clientRef = useRef(null) - const isInitialMount = useRef(true) - - // Initialize the client if it doesn't exist yet - const getClient = useCallback(() => { - if (!clientRef.current) { - clientRef.current = new McpClient( - options.url, - { - clientName: options.clientName || 'MCP React Client', - clientUri: options.clientUri || window.location.origin, - callbackUrl: options.callbackUrl || new URL('/oauth/callback', window.location.origin).toString(), - storageKeyPrefix: options.storageKeyPrefix || 'mcp:auth', - clientConfig: options.clientConfig || { - name: 'mcp-react-client', - version: '0.1.0', - }, - debug: options.debug || false, - autoRetry: options.autoRetry || false, - autoReconnect: options.autoReconnect || 3000, - popupFeatures: options.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes', - }, - { - onStateChange: setState, - onToolsChange: setTools, - onErrorChange: setError, - onLogChange: setLog, - onAuthUrlChange: setAuthUrl, - }, - ) - } - return clientRef.current - }, [ - options.url, - options.clientName, - options.clientUri, - options.callbackUrl, - options.storageKeyPrefix, - options.clientConfig, - options.debug, - options.autoRetry, - options.autoReconnect, - options.popupFeatures, - ]) - - // Connect on initial mount - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false - const client = getClient() - client.connect() - } - }, [getClient]) - - // Auto-retry on failure - useEffect(() => { - if (state === 'failed' && options.autoRetry) { - const delay = typeof options.autoRetry === 'number' ? options.autoRetry : 5000 - const timeoutId = setTimeout(() => { - const client = getClient() - client.retry() - }, delay) - - return () => { - clearTimeout(timeoutId) - } - } - }, [state, options.autoRetry, getClient]) - - // Set up message listener for auth callback - useEffect(() => { - const messageHandler = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return - - if (event.data && event.data.type === 'mcp_auth_callback') { - const client = getClient() - - // If code is provided, use it; otherwise, assume tokens are already in localStorage - if (event.data.code) { - client.handleAuthCompletion(event.data.code).catch((err) => { - console.error('Auth callback error:', err) - }) - } else { - // Tokens were already exchanged by the popup - client.handleAuthCompletion('TOKENS_ALREADY_EXCHANGED').catch((err) => { - console.error('Auth callback error:', err) - }) - } - } - } - - window.addEventListener('message', messageHandler) - return () => { - window.removeEventListener('message', messageHandler) - } - }, [getClient]) - - // Clean up on unmount - useEffect(() => { - return () => { - if (clientRef.current) { - clientRef.current.disconnect() - } - } - }, []) - - // Public methods - proxied to the client - const callTool = useCallback( - async (name: string, args?: Record) => { - const client = getClient() - return client.callTool(name, args) - }, - [getClient], - ) - - const retry = useCallback(() => { - const client = getClient() - client.retry() - }, [getClient]) - - const disconnect = useCallback(async () => { - const client = getClient() - await client.disconnect() - }, [getClient]) - - const authenticate = useCallback(async (): Promise => { - const client = getClient() - return client.authenticate() - }, [getClient]) - - const clearStorage = useCallback(() => { - const client = getClient() - client.clearStorage() - }, [getClient]) - - return { - state, - tools, - error, - log, - authUrl, - callTool, - retry, - disconnect, - authenticate, - clearStorage, - } -} - -/** - * onMcpAuthorization is invoked when the oauth flow completes. This is usually mounted - * on /oauth/callback, and passed the entire URL query parameters. This first uses the state - * parameter to look up in LocalStorage the context for the current auth flow, and then - * completes the flow by exchanging the authorization code for an access token. - * - * Once it's updated LocalStorage with the auth token, it will post a message back to the original - * window to inform any running `useMcp` hooks that the auth flow is complete. - */ -export async function onMcpAuthorization( - query: Record, - { - storageKeyPrefix = 'mcp:auth', - }: { - storageKeyPrefix?: string - } = {}, -) { - try { - // Extract the authorization code and state - const code = query.code - const state = query.state - - if (!code) { - throw new Error('No authorization code received') - } - - if (!state) { - throw new Error('No state parameter received') - } - - // Find the matching auth state in localStorage - const stateKey = `${storageKeyPrefix}:state_${state}` - const storedState = localStorage.getItem(stateKey) - console.log({ stateKey, storedState }) - if (!storedState) { - throw new Error('No matching auth state found in storage') - } - const { authorizationUrl, serverUrlHash, metadata, expiry } = JSON.parse(storedState) - if (expiry < Date.now()) { - throw new Error('Auth state has expired') - } - - // Find all related auth data with the same prefix and server hash - const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info` - const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier` - console.log({ authorizationUrl, clientInfoKey, codeVerifierKey }) - - const clientInfoStr = localStorage.getItem(clientInfoKey) - const codeVerifier = localStorage.getItem(codeVerifierKey) - - if (!clientInfoStr) { - throw new Error('No client information found in storage') - } - - if (!codeVerifier) { - throw new Error('No code verifier found in storage') - } - - // Parse client info - const clientInfo = JSON.parse(clientInfoStr) as OAuthClientInformation - - const tokens = await exchangeAuthorization(new URL('/', authorizationUrl), { - metadata, - clientInformation: clientInfo, - authorizationCode: code, - codeVerifier, - }) - - // Save the tokens - const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens` - console.log({ tokensKey, tokens }) - localStorage.setItem(tokensKey, JSON.stringify(tokens)) - - // Post message back to the parent window - if (window.opener && !window.opener.closed) { - window.opener.postMessage( - { - type: 'mcp_auth_callback', - // Don't send the code back since we've already done the token exchange - // This signals to the main window that tokens are already in localStorage - }, - window.location.origin, - ) - // Close the popup - window.close() - } else { - // If no parent window, we're in a redirect flow - // Redirect back to the main page - window.location.href = '/' - } - - return { success: true } - } catch (error) { - console.error('Error in MCP authorization:', error) - - // Create a readable error message for display - const errorMessage = error instanceof Error ? error.message : String(error) - - // If the popup is still open, show the error - const errorHtml = ` - - - Authentication Error - - - -

Authentication Error

-
-

${errorMessage}

-
-

You can close this window and try again.

- - - ` - - document.body.innerHTML = errorHtml - - return { success: false, error: errorMessage } - } -} diff --git a/tsconfig.json b/tsconfig.json index cd9cfa1..9bfece1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "esModuleInterop": true, "noEmit": true, "lib": ["ES2022", "DOM"], - "types": ["node", "react"], + "types": ["node"], "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }