diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 005fbc3..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,33 +0,0 @@ -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 bc0a4a7..c7f9e73 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,16 @@ To bypass authentication, or to emit custom headers on all requests to your remo "https://remote.mcp.server/sse", "--header", "Authorization: Bearer ${AUTH_TOKEN}" - ], - "env": { - "AUTH_TOKEN": "..." - } + ] }, + "env": { + "AUTH_TOKEN": "..." + } } } ``` -**Note:** Cursor 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: +**Note:** Cursor has a bug where spaces inside `args` aren't escaped when it invokes `npx`, which ends up mangling these values. You can work around it using: ```jsonc { @@ -65,11 +65,11 @@ To bypass authentication, or to emit custom headers on all requests to your remo "https://remote.mcp.server/sse", "--header", "Authorization:${AUTH_HEADER}" // note no spaces around ':' - ], - "env": { - "AUTH_HEADER": "Bearer " // spaces OK in env vars - } + ] }, +"env": { + "AUTH_HEADER": "Bearer " // spaces OK in env vars +} ``` ### Flags @@ -114,23 +114,6 @@ To bypass authentication, or to emit custom headers on all requests to your remo ] ``` -### Transport Strategies - -MCP Remote supports different transport strategies when connecting to an MCP server. This allows you to control whether it uses Server-Sent Events (SSE) or HTTP transport, and in what order it tries them. - -Specify the transport strategy with the `--transport` flag: - -```bash -npx mcp-remote https://example.remote/server --transport sse-only -``` - -**Available Strategies:** - -- `http-first` (default): Tries HTTP transport first, falls back to SSE if HTTP fails with a 404 error -- `sse-first`: Tries SSE transport first, falls back to HTTP if SSE fails with a 405 error -- `http-only`: Only uses HTTP transport, fails if the server doesn't support it -- `sse-only`: Only uses SSE transport, fails if the server doesn't support it - ### Claude Desktop [Official Docs](https://modelcontextprotocol.io/quickstart/user) diff --git a/package.json b/package.json index bf4892f..eeb95b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@kvant/mcp-remote", - "version": "0.1.5", + "name": "mcp-remote", + "version": "0.1.0", "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", "keywords": [ "mcp", @@ -31,12 +31,13 @@ "express": "^4.21.2", "open": "^10.1.0" }, - "packageManager": "pnpm@10.11.0", "devDependencies": { - "@modelcontextprotocol/sdk": "^1.11.2", + "@modelcontextprotocol/sdk": "^1.10.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" @@ -52,6 +53,8 @@ "dts": true, "clean": true, "outDir": "dist", - "external": [] + "external": [ + "react" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0720bf..48d8491 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,17 +16,23 @@ importers: version: 10.1.0 devDependencies: '@modelcontextprotocol/sdk': - specifier: ^1.11.2 - version: 1.11.2 + specifier: ^1.10.2 + version: 1.10.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) @@ -211,8 +217,8 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@modelcontextprotocol/sdk@1.11.2': - resolution: {integrity: sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==} + '@modelcontextprotocol/sdk@1.10.2': + resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==} engines: {node: '>=18'} '@pkgjs/parseargs@0.11.0': @@ -344,6 +350,9 @@ 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==} @@ -470,6 +479,9 @@ 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: @@ -887,6 +899,10 @@ 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'} @@ -1206,7 +1222,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@modelcontextprotocol/sdk@1.11.2': + '@modelcontextprotocol/sdk@1.10.2': dependencies: content-type: 1.0.5 cors: 2.8.5 @@ -1318,6 +1334,10 @@ 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 @@ -1454,6 +1474,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.1.3: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -1874,6 +1896,8 @@ snapshots: iconv-lite: 0.6.3 unpipe: 1.0.0 + react@19.0.0: {} + readdirp@4.1.2: {} resolve-from@5.0.0: {} diff --git a/src/client.ts b/src/client.ts index d87599c..4e0c14c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -151,7 +151,7 @@ async function runClient( } // Parse command-line arguments and run the client -parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts [callback-port]') +parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [callback-port]') .then(({ serverUrl, callbackPort, headers, transportStrategy }) => { return runClient(serverUrl, callbackPort, headers, transportStrategy) }) diff --git a/src/lib/node-oauth-client-provider.ts b/src/lib/node-oauth-client-provider.ts index 0826844..1e58b7e 100644 --- a/src/lib/node-oauth-client-provider.ts +++ b/src/lib/node-oauth-client-provider.ts @@ -1,8 +1,9 @@ import open from 'open' import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' import { + OAuthClientInformation, OAuthClientInformationFull, - OAuthClientInformationFullSchema, + OAuthClientInformationSchema, OAuthTokens, OAuthTokensSchema, } from '@modelcontextprotocol/sdk/shared/auth.js' @@ -36,7 +37,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { } get redirectUrl(): string { - return `http://localhost:${this.options.callbackPort}${this.callbackPath}` + return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}` } get clientMetadata() { @@ -56,9 +57,9 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * Gets the client information if it exists * @returns The client information or undefined */ - async clientInformation(): Promise { + async clientInformation(): Promise { // log('Reading client info') - return readJsonFile(this.serverUrlHash, 'client_info.json', OAuthClientInformationFullSchema) + return readJsonFile(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema) } /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a0a60dc..1b68bef 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -3,13 +3,6 @@ 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' @@ -17,6 +10,10 @@ export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport' // Transport strategy types export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first' +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 @@ -35,21 +32,14 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo let transportToClientClosed = false let transportToServerClosed = false - transportToClient.onmessage = (_message) => { - // TODO: fix types - const message = _message as any + transportToClient.onmessage = (message) => { + // @ts-expect-error TODO 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) => { - // TODO: fix types - const message = _message as any + transportToServer.onmessage = (message) => { + // @ts-expect-error TODO: fix this type log('[Remote→Local]', message.method || message.id) transportToClient.send(message).catch(onClientError) } @@ -172,10 +162,9 @@ export async function connectToRemoteServer( if ( error instanceof Error && shouldAttemptFallback && - (error.message.includes('405') || - error.message.includes('Method Not Allowed') || - error.message.includes('404') || - error.message.includes('Not Found')) + (sseTransport + ? error.message.includes('405') || error.message.includes('Method Not Allowed') + : error.message.includes('404') || error.message.includes('Not Found')) ) { log(`Received error: ${error.message}`) @@ -310,16 +299,7 @@ export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServe log('Auth code received, resolving promise') authCompletedResolve(code) - res.send(` - Authorization successful! - You may close this window and return to the CLI. - - `) + res.send('Authorization successful! You may close this window and return to the CLI.') // Notify main flow that auth code is available options.events.emit('auth-code-received', code) @@ -355,27 +335,6 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { 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 @@ -409,15 +368,15 @@ export async function findAvailablePort(preferredPort?: number): Promise /** * Parses command line arguments for MCP clients and proxies * @param args Command line arguments + * @param defaultPort Default port for the callback server if specified port is unavailable * @param usage Usage message to show on error * @returns A promise that resolves to an object with parsed serverUrl, callbackPort and headers */ -export async function parseCommandLineArgs(args: string[], usage: string) { +export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) { // Process headers const headers: Record = {} - let i = 0 - while (i < args.length) { - if (args[i] === '--header' && i < args.length - 1) { + args.forEach((arg, i) => { + if (arg === '--header' && i < args.length - 1) { const value = args[i + 1] const match = value.match(/^([A-Za-z0-9_-]+):(.*)$/) if (match) { @@ -426,11 +385,8 @@ export async function parseCommandLineArgs(args: string[], usage: string) { 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 @@ -462,28 +418,14 @@ export async function parseCommandLineArgs(args: string[], usage: string) { 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 + // Use the specified port, or find an available one + const callbackPort = specifiedPort || (await findAvailablePort(defaultPort)) 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 + log(`Using specified callback port: ${callbackPort}`) } else { - log(`Using automatically selected callback port: ${availablePort}`) - callbackPort = availablePort + log(`Using automatically selected callback port: ${callbackPort}`) } if (Object.keys(headers).length > 0) { @@ -521,11 +463,6 @@ export function setupSignalHandlers(cleanup: () => Promise) { // Keep the process alive process.stdin.resume() - process.stdin.on('end', async () => { - log('\nShutting down...') - await cleanup() - process.exit(0) - }) } /** diff --git a/src/proxy.ts b/src/proxy.ts index 535bfe2..7263a95 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -135,7 +135,7 @@ to the CA certificate file. If using claude_desktop_config.json, this might look } // Parse command-line arguments and run the proxy -parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts [callback-port]') +parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [callback-port]') .then(({ serverUrl, callbackPort, headers, transportStrategy }) => { return runProxy(serverUrl, callbackPort, headers, transportStrategy) }) diff --git a/tsconfig.json b/tsconfig.json index 9bfece1..cd9cfa1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "esModuleInterop": true, "noEmit": true, "lib": ["ES2022", "DOM"], - "types": ["node"], + "types": ["node", "react"], "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }