Merge branch 'main' into patch-1
This commit is contained in:
commit
7ddf9cba25
11 changed files with 982 additions and 1727 deletions
144
README.md
144
README.md
|
@ -16,17 +16,7 @@ That's where `mcp-remote` comes in. As soon as your chosen MCP client supports r
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Claude Desktop
|
All the most popular MCP clients (Claude Desktop, Cursor & Windsurf) use the following config format:
|
||||||
|
|
||||||
[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`
|
|
||||||
|
|
||||||
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
|
```json
|
||||||
{
|
{
|
||||||
|
@ -34,7 +24,6 @@ If it does not exist yet, [you may need to enable it under Settings > Developer]
|
||||||
"remote-example": {
|
"remote-example": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": [
|
||||||
"-y",
|
|
||||||
"mcp-remote",
|
"mcp-remote",
|
||||||
"https://remote.mcp.server/sse"
|
"https://remote.mcp.server/sse"
|
||||||
]
|
]
|
||||||
|
@ -43,51 +32,72 @@ If it does not exist yet, [you may need to enable it under Settings > Developer]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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 force `mcp-remote` to ignore any existing access tokens and begin the authorization flow anew, pass `--clean`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"args": [
|
||||||
|
"mcp-remote",
|
||||||
|
"https://remote.mcp.server/sse",
|
||||||
|
"--clean"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
* To change which port `mcp-remote` listens for an OAuth redirect (by default `3334`), add an additional argument after the server URL. Note that whatever port you specify, if it is unavailable an open port will be chosen at random.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"args": [
|
||||||
|
"mcp-remote",
|
||||||
|
"https://remote.mcp.server/sse",
|
||||||
|
"9696"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
Restart Claude Desktop to pick up the changes in the configuration file.
|
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
|
Upon restarting, you should see a hammer icon in the bottom right corner
|
||||||
of the input box.
|
of the input box.
|
||||||
|
|
||||||
### Cursor
|
### 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`:
|
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`.
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"remote-example": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"mcp-remote",
|
|
||||||
"https://remote.mcp.server/sse"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windsurf
|
### Windsurf
|
||||||
|
|
||||||
[Official Docs](https://docs.codeium.com/windsurf/mcp)
|
[Official Docs](https://docs.codeium.com/windsurf/mcp). The configuration file is located at `~/.codeium/windsurf/mcp_config.json`.
|
||||||
|
|
||||||
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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building Remote MCP Servers
|
## Building Remote MCP Servers
|
||||||
|
|
||||||
|
@ -106,7 +116,17 @@ 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!
|
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
|
### Check your Node version
|
||||||
|
|
||||||
|
@ -144,20 +164,10 @@ this might look like:
|
||||||
|
|
||||||
### Check the logs
|
### Check the logs
|
||||||
|
|
||||||
[Follow Claude Desktop logs in real-time](https://modelcontextprotocol.io/docs/tools/debugging#debugging-in-claude-desktop)
|
* [Follow Claude Desktop logs in real-time](https://modelcontextprotocol.io/docs/tools/debugging#debugging-in-claude-desktop)
|
||||||
|
* MacOS / Linux:<br/>`tail -n 20 -F ~/Library/Logs/Claude/mcp*.log`
|
||||||
MacOS / Linux:
|
* For bash on WSL:<br/>`tail -n 20 -f "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log"`
|
||||||
|
* Powershell: <br/>`Get-Content "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log" -Wait -Tail 20`
|
||||||
`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"`
|
|
||||||
|
|
||||||
or Powershell:
|
|
||||||
|
|
||||||
`Get-Content "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log" -Wait -Tail 20`
|
|
||||||
|
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
|
@ -169,3 +179,13 @@ Token exchange failed: HTTP 400
|
||||||
```
|
```
|
||||||
|
|
||||||
You can run `rm -rf ~/.mcp-auth` to clear any locally stored state and tokens.
|
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. Pair this with `--clean` or after running `rm -rf ~/.mcp-auth` to see if stale credentials are your problem, otherwise hopefully the issue will be more obvious in these logs than those in your MCP client.
|
||||||
|
|
30
package.json
30
package.json
|
@ -1,21 +1,26 @@
|
||||||
{
|
{
|
||||||
"name": "mcp-remote",
|
"name": "mcp-remote",
|
||||||
"version": "0.0.10",
|
"version": "0.0.15",
|
||||||
|
"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 <glen@cloudflare.com>",
|
||||||
|
"repository": "https://github.com/geelen/mcp-remote",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
|
||||||
"mcp-remote": "dist/cli/proxy.js"
|
|
||||||
},
|
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"exports": {
|
"main": "dist/index.js",
|
||||||
"./react": {
|
"bin": {
|
||||||
"types": "./dist/react/index.d.ts",
|
"mcp-remote": "dist/proxy.js",
|
||||||
"require": "./dist/react/index.js",
|
"mcp-remote-client": "dist/client.js"
|
||||||
"import": "./dist/react/index.js"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
|
@ -39,9 +44,8 @@
|
||||||
},
|
},
|
||||||
"tsup": {
|
"tsup": {
|
||||||
"entry": [
|
"entry": [
|
||||||
"src/cli/client.ts",
|
"src/client.ts",
|
||||||
"src/cli/proxy.ts",
|
"src/proxy.ts"
|
||||||
"src/react/index.ts"
|
|
||||||
],
|
],
|
||||||
"format": [
|
"format": [
|
||||||
"esm"
|
"esm"
|
||||||
|
|
|
@ -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<T>(filename: string, schema: any): Promise<T | undefined> {
|
|
||||||
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<string> {
|
|
||||||
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<OAuthClientInformation | undefined> {
|
|
||||||
return this.readFile<OAuthClientInformation>('client_info.json', OAuthClientInformationSchema)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves client information
|
|
||||||
* @param clientInformation The client information to save
|
|
||||||
*/
|
|
||||||
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
|
|
||||||
await this.writeFile('client_info.json', clientInformation)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the OAuth tokens if they exist
|
|
||||||
* @returns The OAuth tokens or undefined
|
|
||||||
*/
|
|
||||||
async tokens(): Promise<OAuthTokens | undefined> {
|
|
||||||
return this.readFile<OAuthTokens>('tokens.json', OAuthTokensSchema)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves OAuth tokens
|
|
||||||
* @param tokens The tokens to save
|
|
||||||
*/
|
|
||||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
await this.writeTextFile('code_verifier.txt', codeVerifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the PKCE code verifier
|
|
||||||
* @returns The code verifier
|
|
||||||
*/
|
|
||||||
async codeVerifier(): Promise<string> {
|
|
||||||
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<string> => {
|
|
||||||
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<number> {
|
|
||||||
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<void>) {
|
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
console.error('\nShutting down...')
|
|
||||||
await cleanup()
|
|
||||||
process.exit(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keep the process alive
|
|
||||||
process.stdin.resume()
|
|
||||||
}
|
|
|
@ -4,7 +4,10 @@
|
||||||
* MCP Client with OAuth support
|
* MCP Client with OAuth support
|
||||||
* A command-line client that connects to an MCP server using SSE with OAuth authentication.
|
* A command-line client that connects to an MCP server using SSE with OAuth authentication.
|
||||||
*
|
*
|
||||||
* Run with: npx tsx client.ts https://example.remote/server [callback-port]
|
* Run with: npx tsx client.ts [--clean] https://example.remote/server [callback-port]
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --clean: Deletes stored configuration before reading, ensuring a fresh session
|
||||||
*
|
*
|
||||||
* If callback-port is not specified, an available port will be automatically selected.
|
* If callback-port is not specified, an available port will be automatically selected.
|
||||||
*/
|
*/
|
||||||
|
@ -14,32 +17,47 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||||
import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
|
import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||||
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||||
import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js'
|
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
|
||||||
|
import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, getServerUrlHash } from './lib/utils'
|
||||||
|
import { coordinateAuth } from './lib/coordination'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main function to run the client
|
* Main function to run the client
|
||||||
*/
|
*/
|
||||||
async function runClient(serverUrl: string, callbackPort: number) {
|
async function runClient(serverUrl: string, callbackPort: number, clean: boolean = false) {
|
||||||
// Set up event emitter for auth flow
|
// Set up event emitter for auth flow
|
||||||
const events = new EventEmitter()
|
const events = new EventEmitter()
|
||||||
|
|
||||||
|
// Get the server URL hash for lockfile operations
|
||||||
|
const serverUrlHash = getServerUrlHash(serverUrl)
|
||||||
|
|
||||||
|
// Coordinate authentication with other instances
|
||||||
|
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events)
|
||||||
|
|
||||||
// Create the OAuth client provider
|
// Create the OAuth client provider
|
||||||
const authProvider = new NodeOAuthClientProvider({
|
const authProvider = new NodeOAuthClientProvider({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
callbackPort,
|
callbackPort,
|
||||||
clientName: 'MCP CLI Client',
|
clientName: 'MCP CLI Client',
|
||||||
|
clean,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If auth was completed by another instance, just log that we'll use the auth from disk
|
||||||
|
if (skipBrowserAuth) {
|
||||||
|
log('Authentication was completed by another instance - will use tokens from disk...')
|
||||||
|
// TODO: remove, the callback is happening before the tokens are exchanged
|
||||||
|
// so we're slightly too early
|
||||||
|
await new Promise((res) => setTimeout(res, 1_000))
|
||||||
|
}
|
||||||
|
|
||||||
// Create the client
|
// Create the client
|
||||||
const client = new Client(
|
const client = new Client(
|
||||||
{
|
{
|
||||||
name: 'mcp-cli',
|
name: 'mcp-remote',
|
||||||
version: '0.1.0',
|
version: MCP_REMOTE_VERSION,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {},
|
||||||
sampling: {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -50,15 +68,15 @@ async function runClient(serverUrl: string, callbackPort: number) {
|
||||||
|
|
||||||
// Set up message and error handlers
|
// Set up message and error handlers
|
||||||
transport.onmessage = (message) => {
|
transport.onmessage = (message) => {
|
||||||
console.log('Received message:', JSON.stringify(message, null, 2))
|
log('Received message:', JSON.stringify(message, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.onerror = (error) => {
|
transport.onerror = (error) => {
|
||||||
console.error('Transport error:', error)
|
log('Transport error:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
console.log('Connection closed.')
|
log('Connection closed.')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
return transport
|
return transport
|
||||||
|
@ -66,16 +84,9 @@ async function runClient(serverUrl: string, callbackPort: number) {
|
||||||
|
|
||||||
const transport = initTransport()
|
const transport = initTransport()
|
||||||
|
|
||||||
// Set up an HTTP server to handle OAuth callback
|
|
||||||
const { server, waitForAuthCode } = setupOAuthCallbackServer({
|
|
||||||
port: callbackPort,
|
|
||||||
path: '/oauth/callback',
|
|
||||||
events,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set up cleanup handler
|
// Set up cleanup handler
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
console.log('\nClosing connection...')
|
log('\nClosing connection...')
|
||||||
await client.close()
|
await client.close()
|
||||||
server.close()
|
server.close()
|
||||||
}
|
}
|
||||||
|
@ -83,44 +94,44 @@ async function runClient(serverUrl: string, callbackPort: number) {
|
||||||
|
|
||||||
// Try to connect
|
// Try to connect
|
||||||
try {
|
try {
|
||||||
console.log('Connecting to server...')
|
log('Connecting to server...')
|
||||||
await client.connect(transport)
|
await client.connect(transport)
|
||||||
console.log('Connected successfully!')
|
log('Connected successfully!')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
||||||
console.log('Authentication required. Waiting for authorization...')
|
log('Authentication required. Waiting for authorization...')
|
||||||
|
|
||||||
// Wait for the authorization code from the callback
|
// Wait for the authorization code from the callback or another instance
|
||||||
const code = await waitForAuthCode()
|
const code = await waitForAuthCode()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Completing authorization...')
|
log('Completing authorization...')
|
||||||
await transport.finishAuth(code)
|
await transport.finishAuth(code)
|
||||||
|
|
||||||
// Reconnect after authorization with a new transport
|
// Reconnect after authorization with a new transport
|
||||||
console.log('Connecting after authorization...')
|
log('Connecting after authorization...')
|
||||||
await client.connect(initTransport())
|
await client.connect(initTransport())
|
||||||
|
|
||||||
console.log('Connected successfully!')
|
log('Connected successfully!')
|
||||||
|
|
||||||
// Request tools list after auth
|
// Request tools list after auth
|
||||||
console.log('Requesting tools list...')
|
log('Requesting tools list...')
|
||||||
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||||
console.log('Tools:', JSON.stringify(tools, null, 2))
|
log('Tools:', JSON.stringify(tools, null, 2))
|
||||||
|
|
||||||
// Request resources list after auth
|
// Request resources list after auth
|
||||||
console.log('Requesting resource list...')
|
log('Requesting resource list...')
|
||||||
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
||||||
console.log('Resources:', JSON.stringify(resources, null, 2))
|
log('Resources:', JSON.stringify(resources, null, 2))
|
||||||
|
|
||||||
console.log('Listening for messages. Press Ctrl+C to exit.')
|
log('Listening for messages. Press Ctrl+C to exit.')
|
||||||
} catch (authError) {
|
} catch (authError) {
|
||||||
console.error('Authorization error:', authError)
|
log('Authorization error:', authError)
|
||||||
server.close()
|
server.close()
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Connection error:', error)
|
log('Connection error:', error)
|
||||||
server.close()
|
server.close()
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
@ -128,29 +139,29 @@ async function runClient(serverUrl: string, callbackPort: number) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request tools list
|
// Request tools list
|
||||||
console.log('Requesting tools list...')
|
log('Requesting tools list...')
|
||||||
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||||
console.log('Tools:', JSON.stringify(tools, null, 2))
|
log('Tools:', JSON.stringify(tools, null, 2))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Error requesting tools list:', e)
|
log('Error requesting tools list:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request resources list
|
// Request resources list
|
||||||
console.log('Requesting resource list...')
|
log('Requesting resource list...')
|
||||||
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
||||||
console.log('Resources:', JSON.stringify(resources, null, 2))
|
log('Resources:', JSON.stringify(resources, null, 2))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Error requesting resources list:', e)
|
log('Error requesting resources list:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Listening for messages. Press Ctrl+C to exit.')
|
log('Listening for messages. Press Ctrl+C to exit.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse command-line arguments and run the client
|
// Parse command-line arguments and run the client
|
||||||
parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts <https://server-url> [callback-port]')
|
parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [--clean] <https://server-url> [callback-port]')
|
||||||
.then(({ serverUrl, callbackPort }) => {
|
.then(({ serverUrl, callbackPort, clean }) => {
|
||||||
return runClient(serverUrl, callbackPort)
|
return runClient(serverUrl, callbackPort, clean)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Fatal error:', error)
|
console.error('Fatal error:', error)
|
188
src/lib/coordination.ts
Normal file
188
src/lib/coordination.ts
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a process with the given PID is running
|
||||||
|
* @param pid The process ID to check
|
||||||
|
* @returns True if the process is running, false otherwise
|
||||||
|
*/
|
||||||
|
export async function isPidRunning(pid: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0) // Doesn't kill the process, just checks if it exists
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a lockfile is valid (process running and endpoint accessible)
|
||||||
|
* @param lockData The lockfile data
|
||||||
|
* @returns True if the lockfile is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export async function isLockValid(lockData: LockfileData): Promise<boolean> {
|
||||||
|
// Check if the lockfile is too old (over 30 minutes)
|
||||||
|
const MAX_LOCK_AGE = 30 * 60 * 1000 // 30 minutes
|
||||||
|
if (Date.now() - lockData.timestamp > MAX_LOCK_AGE) {
|
||||||
|
log('Lockfile is too old')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the process is still running
|
||||||
|
if (!(await isPidRunning(lockData.pid))) {
|
||||||
|
log('Process from lockfile is not running')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the endpoint is accessible
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 1000)
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeout)
|
||||||
|
return response.status === 200 || response.status === 202
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error connecting to auth server: ${(error as Error).message}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for authentication from another server instance
|
||||||
|
* @param port The port to connect to
|
||||||
|
* @returns True if authentication completed successfully, false otherwise
|
||||||
|
*/
|
||||||
|
export async function waitForAuthentication(port: number): Promise<boolean> {
|
||||||
|
log(`Waiting for authentication from the server on port ${port}...`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const url = `http://127.0.0.1:${port}/wait-for-auth`
|
||||||
|
log(`Querying: ${url}`)
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
// Auth completed, but we don't return the code anymore
|
||||||
|
log(`Authentication completed by other instance`)
|
||||||
|
return true
|
||||||
|
} else if (response.status === 202) {
|
||||||
|
// Continue polling
|
||||||
|
log(`Authentication still in progress`)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} else {
|
||||||
|
log(`Unexpected response status: ${response.status}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error waiting for authentication: ${(error as Error).message}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates authentication between multiple instances of the client/proxy
|
||||||
|
* @param serverUrlHash The hash of the server URL
|
||||||
|
* @param callbackPort The port to use for the callback server
|
||||||
|
* @param events The event emitter to use for signaling
|
||||||
|
* @returns An object with the server, waitForAuthCode function, and a flag indicating if browser auth can be skipped
|
||||||
|
*/
|
||||||
|
export async function coordinateAuth(
|
||||||
|
serverUrlHash: string,
|
||||||
|
callbackPort: number,
|
||||||
|
events: EventEmitter,
|
||||||
|
): Promise<{ server: Server; waitForAuthCode: () => Promise<string>; skipBrowserAuth: boolean }> {
|
||||||
|
// Check for a lockfile
|
||||||
|
const lockData = await checkLockfile(serverUrlHash)
|
||||||
|
|
||||||
|
// If there's a valid lockfile, try to use the existing auth process
|
||||||
|
if (lockData && (await isLockValid(lockData))) {
|
||||||
|
log(`Another instance is handling authentication on port ${lockData.port}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to wait for the authentication to complete
|
||||||
|
const authCompleted = await waitForAuthentication(lockData.port)
|
||||||
|
if (authCompleted) {
|
||||||
|
log('Authentication completed by another instance')
|
||||||
|
|
||||||
|
// Setup a dummy server - the client will use tokens directly from disk
|
||||||
|
const dummyServer = express().listen(0) // Listen on any available port
|
||||||
|
|
||||||
|
// This shouldn't actually be called in normal operation, but provide it for API compatibility
|
||||||
|
const dummyWaitForAuthCode = () => {
|
||||||
|
log('WARNING: waitForAuthCode called in secondary instance - this is unexpected')
|
||||||
|
// Return a promise that never resolves - the client should use the tokens from disk instead
|
||||||
|
return new Promise<string>(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: dummyServer,
|
||||||
|
waitForAuthCode: dummyWaitForAuthCode,
|
||||||
|
skipBrowserAuth: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('Taking over authentication process...')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error waiting for authentication: ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, the other process didn't complete auth successfully
|
||||||
|
await deleteLockfile(serverUrlHash)
|
||||||
|
} else if (lockData) {
|
||||||
|
// Invalid lockfile, delete its
|
||||||
|
log('Found invalid lockfile, deleting it')
|
||||||
|
await deleteLockfile(serverUrlHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create our own lockfile
|
||||||
|
const { server, waitForAuthCode, authCompletedPromise } = setupOAuthCallbackServerWithLongPoll({
|
||||||
|
port: callbackPort,
|
||||||
|
path: '/oauth/callback',
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the actual port the server is running on
|
||||||
|
const address = server.address() as AddressInfo
|
||||||
|
const actualPort = address.port
|
||||||
|
|
||||||
|
log(`Creating lockfile for server ${serverUrlHash} with process ${process.pid} on port ${actualPort}`)
|
||||||
|
await createLockfile(serverUrlHash, process.pid, actualPort)
|
||||||
|
|
||||||
|
// Make sure lockfile is deleted on process exit
|
||||||
|
const cleanupHandler = async () => {
|
||||||
|
try {
|
||||||
|
log(`Cleaning up lockfile for server ${serverUrlHash}`)
|
||||||
|
await deleteLockfile(serverUrlHash)
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error cleaning up lockfile: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once('exit', () => {
|
||||||
|
try {
|
||||||
|
// Synchronous version for 'exit' event since we can't use async here
|
||||||
|
const configPath = getConfigFilePath(serverUrlHash, 'lock.json')
|
||||||
|
require('fs').unlinkSync(configPath)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also handle SIGINT separately
|
||||||
|
process.once('SIGINT', async () => {
|
||||||
|
await cleanupHandler()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
waitForAuthCode,
|
||||||
|
skipBrowserAuth: false
|
||||||
|
}
|
||||||
|
}
|
247
src/lib/mcp-auth-config.ts
Normal file
247
src/lib/mcp-auth-config.ts
Normal file
|
@ -0,0 +1,247 @@
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known configuration file names that might need to be cleaned
|
||||||
|
*/
|
||||||
|
export const knownConfigFiles = ['client_info.json', 'tokens.json', 'code_verifier.txt', 'lock.json']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
const lockData: LockfileData = {
|
||||||
|
pid,
|
||||||
|
port,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
await writeJsonFile(serverUrlHash, 'lock.json', lockData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a lockfile exists for the given server
|
||||||
|
* @param serverUrlHash The hash of the server URL
|
||||||
|
* @returns The lockfile data or null if it doesn't exist
|
||||||
|
*/
|
||||||
|
export async function checkLockfile(serverUrlHash: string): Promise<LockfileData | null> {
|
||||||
|
try {
|
||||||
|
const lockfile = await readJsonFile<LockfileData>(serverUrlHash, 'lock.json', {
|
||||||
|
async parseAsync(data: any) {
|
||||||
|
if (typeof data !== 'object' || data === null) return null
|
||||||
|
if (typeof data.pid !== 'number' || typeof data.port !== 'number' || typeof data.timestamp !== 'number') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return data as LockfileData
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return lockfile || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the lockfile for the given server
|
||||||
|
* @param serverUrlHash The hash of the server URL
|
||||||
|
*/
|
||||||
|
export async function deleteLockfile(serverUrlHash: string): Promise<void> {
|
||||||
|
await deleteConfigFile(serverUrlHash, 'lock.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all known configuration files for a specific server
|
||||||
|
* @param serverUrlHash The hash of the server URL
|
||||||
|
*/
|
||||||
|
export async function cleanServerConfig(serverUrlHash: string): Promise<void> {
|
||||||
|
log(`Cleaning configuration files for server: ${serverUrlHash}`)
|
||||||
|
for (const filename of knownConfigFiles) {
|
||||||
|
await deleteConfigFile(serverUrlHash, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
* @param clean Whether to clean (delete) before reading
|
||||||
|
* @returns The parsed file content or undefined if the file doesn't exist
|
||||||
|
*/
|
||||||
|
export async function readJsonFile<T>(
|
||||||
|
serverUrlHash: string,
|
||||||
|
filename: string,
|
||||||
|
schema: any,
|
||||||
|
clean: boolean = false,
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir()
|
||||||
|
|
||||||
|
// If clean flag is set, delete the file before trying to read it
|
||||||
|
if (clean) {
|
||||||
|
await deleteConfigFile(serverUrlHash, filename)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = getConfigFilePath(serverUrlHash, filename)
|
||||||
|
const 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<void> {
|
||||||
|
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
|
||||||
|
* @param clean Whether to clean (delete) before reading
|
||||||
|
* @returns The file content as a string
|
||||||
|
*/
|
||||||
|
export async function readTextFile(
|
||||||
|
serverUrlHash: string,
|
||||||
|
filename: string,
|
||||||
|
errorMessage?: string,
|
||||||
|
clean: boolean = false,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir()
|
||||||
|
|
||||||
|
// If clean flag is set, delete the file before trying to read it
|
||||||
|
if (clean) {
|
||||||
|
await deleteConfigFile(serverUrlHash, filename)
|
||||||
|
throw new Error('File deleted due to clean flag')
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = getConfigFilePath(serverUrlHash, filename)
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir()
|
||||||
|
const filePath = getConfigFilePath(serverUrlHash, filename)
|
||||||
|
await fs.writeFile(filePath, text, 'utf-8')
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error writing ${filename}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
125
src/lib/node-oauth-client-provider.ts
Normal file
125
src/lib/node-oauth-client-provider.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import open from 'open'
|
||||||
|
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||||
|
import {
|
||||||
|
OAuthClientInformation,
|
||||||
|
OAuthClientInformationFull,
|
||||||
|
OAuthClientInformationSchema,
|
||||||
|
OAuthTokens,
|
||||||
|
OAuthTokensSchema,
|
||||||
|
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||||
|
import type { OAuthProviderOptions } from './types'
|
||||||
|
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile, cleanServerConfig } from './mcp-auth-config'
|
||||||
|
import { getServerUrlHash, log } 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
|
||||||
|
// If clean flag is set, proactively clean all config files for this server
|
||||||
|
if (options.clean) {
|
||||||
|
cleanServerConfig(this.serverUrlHash).catch((err) => {
|
||||||
|
log('Error cleaning server config:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the client information if it exists
|
||||||
|
* @returns The client information or undefined
|
||||||
|
*/
|
||||||
|
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||||
|
// log('Reading client info')
|
||||||
|
return readJsonFile<OAuthClientInformation>(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema, this.options.clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves client information
|
||||||
|
* @param clientInformation The client information to save
|
||||||
|
*/
|
||||||
|
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
|
||||||
|
// 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<OAuthTokens | undefined> {
|
||||||
|
// log('Reading tokens')
|
||||||
|
// console.log(new Error().stack)
|
||||||
|
return readJsonFile<OAuthTokens>(this.serverUrlHash, 'tokens.json', OAuthTokensSchema, this.options.clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves OAuth tokens
|
||||||
|
* @param tokens The tokens to save
|
||||||
|
*/
|
||||||
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
// log('Saving code verifier')
|
||||||
|
await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the PKCE code verifier
|
||||||
|
* @returns The code verifier
|
||||||
|
*/
|
||||||
|
async codeVerifier(): Promise<string> {
|
||||||
|
// log('Reading code verifier')
|
||||||
|
return await readTextFile(this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session', this.options.clean)
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,8 @@ export interface OAuthProviderOptions {
|
||||||
clientName?: string
|
clientName?: string
|
||||||
/** Client URI to use for OAuth registration */
|
/** Client URI to use for OAuth registration */
|
||||||
clientUri?: string
|
clientUri?: string
|
||||||
|
/** Whether to clean stored configuration before reading */
|
||||||
|
clean?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
245
src/lib/utils.ts
245
src/lib/utils.ts
|
@ -1,8 +1,19 @@
|
||||||
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||||
|
import { OAuthCallbackServerOptions } from './types'
|
||||||
|
import express from 'express'
|
||||||
|
import net from 'net'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
// Package version from package.json
|
||||||
|
export const MCP_REMOTE_VERSION = require('../../package.json').version
|
||||||
|
|
||||||
const pid = process.pid
|
const pid = process.pid
|
||||||
|
export function log(str: string, ...rest: unknown[]) {
|
||||||
|
// Using stderr so that it doesn't interfere with stdout
|
||||||
|
console.error(`[${pid}] ${str}`, ...rest)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a bidirectional proxy between two transports
|
* Creates a bidirectional proxy between two transports
|
||||||
|
@ -14,13 +25,13 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
|
||||||
|
|
||||||
transportToClient.onmessage = (message) => {
|
transportToClient.onmessage = (message) => {
|
||||||
// @ts-expect-error TODO
|
// @ts-expect-error TODO
|
||||||
console.error('[Local→Remote]', message.method || message.id)
|
log('[Local→Remote]', message.method || message.id)
|
||||||
transportToServer.send(message).catch(onServerError)
|
transportToServer.send(message).catch(onServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
transportToServer.onmessage = (message) => {
|
transportToServer.onmessage = (message) => {
|
||||||
// @ts-expect-error TODO: fix this type
|
// @ts-expect-error TODO: fix this type
|
||||||
console.error('[Remote→Local]', message.method || message.id)
|
log('[Remote→Local]', message.method || message.id)
|
||||||
transportToClient.send(message).catch(onClientError)
|
transportToClient.send(message).catch(onClientError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,11 +56,11 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
|
||||||
transportToServer.onerror = onServerError
|
transportToServer.onerror = onServerError
|
||||||
|
|
||||||
function onClientError(error: Error) {
|
function onClientError(error: Error) {
|
||||||
console.error('Error from local client:', error)
|
log('Error from local client:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onServerError(error: Error) {
|
function onServerError(error: Error) {
|
||||||
console.error('Error from remote server:', error)
|
log('Error from remote server:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,44 +69,256 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
|
||||||
* @param serverUrl The URL of the remote server
|
* @param serverUrl The URL of the remote server
|
||||||
* @param authProvider The OAuth client provider
|
* @param authProvider The OAuth client provider
|
||||||
* @param waitForAuthCode Function to wait for the auth code
|
* @param waitForAuthCode Function to wait for the auth code
|
||||||
|
* @param skipBrowserAuth Whether to skip browser auth and use shared auth
|
||||||
* @returns The connected SSE client transport
|
* @returns The connected SSE client transport
|
||||||
*/
|
*/
|
||||||
export async function connectToRemoteServer(
|
export async function connectToRemoteServer(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
authProvider: OAuthClientProvider,
|
authProvider: OAuthClientProvider,
|
||||||
waitForAuthCode: () => Promise<string>,
|
waitForAuthCode: () => Promise<string>,
|
||||||
|
skipBrowserAuth: boolean = false,
|
||||||
): Promise<SSEClientTransport> {
|
): Promise<SSEClientTransport> {
|
||||||
console.error(`[${pid}] Connecting to remote server: ${serverUrl}`)
|
log(`[${pid}] Connecting to remote server: ${serverUrl}`)
|
||||||
const url = new URL(serverUrl)
|
const url = new URL(serverUrl)
|
||||||
const transport = new SSEClientTransport(url, { authProvider })
|
const transport = new SSEClientTransport(url, { authProvider })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.start()
|
await transport.start()
|
||||||
console.error('Connected to remote server')
|
log('Connected to remote server')
|
||||||
return transport
|
return transport
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
||||||
console.error('Authentication required. Waiting for authorization...')
|
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
|
// Wait for the authorization code from the callback
|
||||||
const code = await waitForAuthCode()
|
const code = await waitForAuthCode()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.error('Completing authorization...')
|
log('Completing authorization...')
|
||||||
await transport.finishAuth(code)
|
await transport.finishAuth(code)
|
||||||
|
|
||||||
// Create a new transport after auth
|
// Create a new transport after auth
|
||||||
const newTransport = new SSEClientTransport(url, { authProvider })
|
const newTransport = new SSEClientTransport(url, { authProvider })
|
||||||
await newTransport.start()
|
await newTransport.start()
|
||||||
console.error('Connected to remote server after authentication')
|
log('Connected to remote server after authentication')
|
||||||
return newTransport
|
return newTransport
|
||||||
} catch (authError) {
|
} catch (authError) {
|
||||||
console.error('Authorization error:', authError)
|
log('Authorization error:', authError)
|
||||||
throw authError
|
throw authError
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Connection error:', error)
|
log('Connection error:', error)
|
||||||
throw 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<string>((resolve) => {
|
||||||
|
authCompletedResolve = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
// Long-polling endpoint
|
||||||
|
app.get('/wait-for-auth', (req, res) => {
|
||||||
|
if (authCode) {
|
||||||
|
// Auth already completed - just return 200 without the actual code
|
||||||
|
// Secondary instances will read tokens from disk
|
||||||
|
log('Auth already completed, returning 200')
|
||||||
|
res.status(200).send('Authentication completed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.poll === 'false') {
|
||||||
|
log('Client requested no long poll, responding with 202')
|
||||||
|
res.status(202).send('Authentication in progress')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long poll - wait for up to 30 seconds
|
||||||
|
const longPollTimeout = setTimeout(() => {
|
||||||
|
log('Long poll timeout reached, responding with 202')
|
||||||
|
res.status(202).send('Authentication in progress')
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
// If auth completes while we're waiting, send the response immediately
|
||||||
|
authCompletedPromise
|
||||||
|
.then(() => {
|
||||||
|
clearTimeout(longPollTimeout)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
log('Auth completed during long poll, responding with 200')
|
||||||
|
res.status(200).send('Authentication completed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
clearTimeout(longPollTimeout)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
log('Auth failed during long poll, responding with 500')
|
||||||
|
res.status(500).send('Authentication failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// OAuth callback endpoint
|
||||||
|
app.get(options.path, (req, res) => {
|
||||||
|
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<string> => {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<number> {
|
||||||
|
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, callbackPort, and clean flag
|
||||||
|
*/
|
||||||
|
export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) {
|
||||||
|
// Check for --clean flag
|
||||||
|
const cleanIndex = args.indexOf('--clean')
|
||||||
|
const clean = cleanIndex !== -1
|
||||||
|
|
||||||
|
// Remove the flag from args if it exists
|
||||||
|
if (clean) {
|
||||||
|
args.splice(cleanIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUrl = args[0]
|
||||||
|
const specifiedPort = args[1] ? parseInt(args[1]) : undefined
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
log(usage)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the specified port, or find an available one
|
||||||
|
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort))
|
||||||
|
|
||||||
|
if (specifiedPort) {
|
||||||
|
log(`Using specified callback port: ${callbackPort}`)
|
||||||
|
} else {
|
||||||
|
log(`Using automatically selected callback port: ${callbackPort}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clean) {
|
||||||
|
log('Clean mode enabled: config files will be reset before reading')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { serverUrl, callbackPort, clean }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up signal handlers for graceful shutdown
|
||||||
|
* @param cleanup Cleanup function to run on shutdown
|
||||||
|
*/
|
||||||
|
export function setupSignalHandlers(cleanup: () => Promise<void>) {
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
log('\nShutting down...')
|
||||||
|
await cleanup()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep the process alive
|
||||||
|
process.stdin.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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')
|
||||||
|
}
|
||||||
|
|
|
@ -4,43 +4,55 @@
|
||||||
* MCP Proxy with OAuth support
|
* MCP Proxy with OAuth support
|
||||||
* A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication.
|
* A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication.
|
||||||
*
|
*
|
||||||
* Run with: npx tsx proxy.ts https://example.remote/server [callback-port]
|
* Run with: npx tsx proxy.ts [--clean] https://example.remote/server [callback-port]
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --clean: Deletes stored configuration before reading, ensuring a fresh session
|
||||||
*
|
*
|
||||||
* If callback-port is not specified, an available port will be automatically selected.
|
* If callback-port is not specified, an available port will be automatically selected.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||||
import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js'
|
import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash } from './lib/utils'
|
||||||
import { connectToRemoteServer, mcpProxy } from '../lib/utils.js'
|
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
|
||||||
|
import { coordinateAuth } from './lib/coordination'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main function to run the proxy
|
* Main function to run the proxy
|
||||||
*/
|
*/
|
||||||
async function runProxy(serverUrl: string, callbackPort: number) {
|
async function runProxy(serverUrl: string, callbackPort: number, clean: boolean = false) {
|
||||||
// Set up event emitter for auth flow
|
// Set up event emitter for auth flow
|
||||||
const events = new EventEmitter()
|
const events = new EventEmitter()
|
||||||
|
|
||||||
|
// Get the server URL hash for lockfile operations
|
||||||
|
const serverUrlHash = getServerUrlHash(serverUrl)
|
||||||
|
|
||||||
|
// Coordinate authentication with other instances
|
||||||
|
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events)
|
||||||
|
|
||||||
// Create the OAuth client provider
|
// Create the OAuth client provider
|
||||||
const authProvider = new NodeOAuthClientProvider({
|
const authProvider = new NodeOAuthClientProvider({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
callbackPort,
|
callbackPort,
|
||||||
clientName: 'MCP CLI Proxy',
|
clientName: 'MCP CLI Proxy',
|
||||||
|
clean,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If auth was completed by another instance, just log that we'll use the auth from disk
|
||||||
|
if (skipBrowserAuth) {
|
||||||
|
log('Authentication was completed by another instance - will use tokens from disk')
|
||||||
|
// TODO: remove, the callback is happening before the tokens are exchanged
|
||||||
|
// so we're slightly too early
|
||||||
|
await new Promise((res) => setTimeout(res, 1_000))
|
||||||
|
}
|
||||||
|
|
||||||
// Create the STDIO transport for local connections
|
// Create the STDIO transport for local connections
|
||||||
const localTransport = new StdioServerTransport()
|
const localTransport = new StdioServerTransport()
|
||||||
|
|
||||||
// Set up an HTTP server to handle OAuth callback
|
|
||||||
const { server, waitForAuthCode } = setupOAuthCallbackServer({
|
|
||||||
port: callbackPort,
|
|
||||||
path: '/oauth/callback',
|
|
||||||
events,
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Connect to remote server with authentication
|
// Connect to remote server with authentication
|
||||||
const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, waitForAuthCode)
|
const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, waitForAuthCode, skipBrowserAuth)
|
||||||
|
|
||||||
// Set up bidirectional proxy between local and remote transports
|
// Set up bidirectional proxy between local and remote transports
|
||||||
mcpProxy({
|
mcpProxy({
|
||||||
|
@ -50,9 +62,9 @@ async function runProxy(serverUrl: string, callbackPort: number) {
|
||||||
|
|
||||||
// Start the local STDIO server
|
// Start the local STDIO server
|
||||||
await localTransport.start()
|
await localTransport.start()
|
||||||
console.error('Local STDIO server running')
|
log('Local STDIO server running')
|
||||||
console.error('Proxy established successfully between local STDIO and remote SSE')
|
log('Proxy established successfully between local STDIO and remote SSE')
|
||||||
console.error('Press Ctrl+C to exit')
|
log('Press Ctrl+C to exit')
|
||||||
|
|
||||||
// Setup cleanup handler
|
// Setup cleanup handler
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
|
@ -62,9 +74,9 @@ async function runProxy(serverUrl: string, callbackPort: number) {
|
||||||
}
|
}
|
||||||
setupSignalHandlers(cleanup)
|
setupSignalHandlers(cleanup)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fatal error:', error)
|
log('Fatal error:', error)
|
||||||
if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) {
|
if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) {
|
||||||
console.error(`You may be behind a VPN!
|
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
|
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:
|
to the CA certificate file. If using claude_desktop_config.json, this might look like:
|
||||||
|
@ -91,11 +103,11 @@ to the CA certificate file. If using claude_desktop_config.json, this might look
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse command-line arguments and run the proxy
|
// Parse command-line arguments and run the proxy
|
||||||
parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]')
|
parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [--clean] <https://server-url> [callback-port]')
|
||||||
.then(({ serverUrl, callbackPort }) => {
|
.then(({ serverUrl, callbackPort, clean }) => {
|
||||||
return runProxy(serverUrl, callbackPort)
|
return runProxy(serverUrl, callbackPort, clean)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Fatal error:', error)
|
log('Fatal error:', error)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
1243
src/react/index.ts
1243
src/react/index.ts
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue